diff --git a/README.md b/README.md index ec4ef24..e9ddd73 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ First, you will need to add `portal` to your `pubspec.yaml`: dependencies: flutter: sdk: flutter - flutter_portal: ^0.3.0 + flutter_portal: ^0.4.0 ``` Then, run `flutter packages get` in your terminal. @@ -247,6 +247,74 @@ Center( ) ``` +## Concepts + +There are a few concepts that are useful to fully understand when using +`flutter_portal`. That is especially true if you want to support custom use +cases, which is easily possible with the abstract API provided. + +In the following, each of the abstract concepts you need to understand are +explained on a high level. You will find them both in class names (e.g. the +`Portal` widget or the `PortalTarget` widget as well as in parameter names). + +### Portal + +A portal (or the portal if you only have one) is the space used for doing all +of the portal work. On a low level, this means that you have one widget that +allows its subtree to place targets and followers that are connected. + +The portal also defines the area (rectangle bounds) that are available to any +followers to be rendered onto the screen. + +In detail, you might wrap your whole `MaterialApp` in a single `Portal` widget, +which would mean that you can use the whole area of your app to render followers +attached to targets that are children of the `Portal` widget. + +### Target + +A target is any place within a portal that can be followed by a follower. This +allows you to attach whatever you want to overlay to a specific place in your +UI, no matter where it moves dynamically. + +On a low level, this means that you wrap the part of your UI that you want to +follow in a `PortalTarget` widget and configure it. + +#### Example + +Imagine you want to display tooltips when an avatar is hovered in your app. In +that case, the avatar would be the portal **target** and could be used to anchor +the tooltip that is overlayed. + +Another example would be a dropdown menu. The widget that shows the current +selection is the *target* and when tapping on it, the dropdown options would be +overlayed through the portal as the follower. + +### Follower + +A follower can only be used in combination with a target. You can use it for +anything that you want to overlay on top of your UI, attached to a target. + +Specifically, this means that you can pass one `follower` to every +`PortalTarget`, which will be displayed above your UI within the portal when +you specify so. + +#### Example + +If you wanted to display an autocomplete text field using `flutter_portal`, +you would want to follow the text field to overlay your autocomplete +suggestions. The widget for the autocomplete suggestions would be the portal +**follower** in that case. + +### Anchor + +Anchors define the layout connection between targets and followers. In general, +anchors are implemented as an abstract API that provides all the information +necessary to support any positioning you want. That means that anchors can be +defined based on the attributes of the associated portal, target, and follower. + +There are a few anchors that are implemented by default, e.g. `Aligned` or +`Filled`. + [overlay]: https://api.flutter.dev/flutter/widgets/Overlay-class.html [overlayentry]: https://api.flutter.dev/flutter/widgets/OverlayEntry-class.html [addpostframecallback]: https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html diff --git a/analysis_options.yaml b/analysis_options.yaml index 7b7f222..724bc00 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,9 +12,6 @@ analyzer: # in this file included_file_warning: ignore - # Causes false positives (https://github.com/dart-lang/sdk/issues/41571 - top_level_function_literal_block: ignore - linter: rules: # Personal preference. I don't find it more readable diff --git a/example/.gitignore b/example/.gitignore index 78b3c2b..282a36d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -29,6 +29,11 @@ .pub-cache/ .pub/ /build/ +android/ +ios/ +linux/ +windows/ +macos/ # Web related lib/generated_plugin_registrant.dart diff --git a/example/lib/contextual_menu.dart b/example/lib/contextual_menu.dart index f8d0550..a255efa 100644 --- a/example/lib/contextual_menu.dart +++ b/example/lib/contextual_menu.dart @@ -40,7 +40,7 @@ class _ContextualMenuExampleState extends State { @override Widget build(BuildContext context) { return Center( - child: ModalEntry( + child: _ModalEntry( visible: _showMenu, onClose: () => setState(() => _showMenu = false), childAnchor: Alignment.topRight, @@ -76,23 +76,20 @@ class Menu extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 10), - child: Card( - elevation: 8, - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - children: children, - ), + return Card( + elevation: 8, + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, ), ), ); } } -class ModalEntry extends StatelessWidget { - const ModalEntry({ +class _ModalEntry extends StatelessWidget { + const _ModalEntry({ Key? key, required this.onClose, required this.menu, @@ -114,12 +111,18 @@ class ModalEntry extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: visible ? onClose : null, - child: PortalEntry( + child: PortalTarget( visible: visible, - portal: menu, + portalFollower: menu, anchor: const Aligned( - target: Alignment.center, - source: Alignment.center, + follower: Alignment.topLeft, + target: Alignment.bottomLeft, + widthFactor: 1, + backup: Aligned( + follower: Alignment.bottomLeft, + target: Alignment.topLeft, + widthFactor: 1, + ), ), child: IgnorePointer( ignoring: visible, diff --git a/example/lib/date_picker.dart b/example/lib/date_picker.dart index d7a3c82..50d7df7 100644 --- a/example/lib/date_picker.dart +++ b/example/lib/date_picker.dart @@ -21,9 +21,9 @@ class DeclarativeDatePicker extends StatelessWidget { @override Widget build(BuildContext context) { - return PortalEntry( + return PortalTarget( visible: visible, - portal: Stack( + portalFollower: Stack( children: [ const Positioned.fill( child: IgnorePointer( diff --git a/example/lib/discovery.dart b/example/lib/discovery.dart index 6d41d47..1ab2bb1 100644 --- a/example/lib/discovery.dart +++ b/example/lib/discovery.dart @@ -76,17 +76,17 @@ class Discovery extends StatelessWidget { return Barrier( visible: visible, onClose: onClose, - child: PortalEntry( + child: PortalTarget( visible: visible, closeDuration: kThemeAnimationDuration, anchor: const Aligned( target: Alignment.center, - source: Alignment.center, + follower: Alignment.center, ), - portal: Stack( + portalFollower: Stack( children: [ CustomPaint( - painter: HolePainter(Theme.of(context).accentColor), + painter: HolePainter(Theme.of(context).colorScheme.secondary), child: TweenAnimationBuilder( duration: kThemeAnimationDuration, curve: Curves.easeOut, @@ -171,10 +171,10 @@ class Barrier extends StatelessWidget { @override Widget build(BuildContext context) { - return PortalEntry( + return PortalTarget( visible: visible, closeDuration: kThemeAnimationDuration, - portal: GestureDetector( + portalFollower: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onClose, child: TweenAnimationBuilder( diff --git a/example/lib/medium_clap.dart b/example/lib/medium_clap.dart index 4f726f5..69b3a50 100644 --- a/example/lib/medium_clap.dart +++ b/example/lib/medium_clap.dart @@ -39,15 +39,15 @@ class _ClapButtonState extends State { @override Widget build(BuildContext context) { - return PortalEntry( + return PortalTarget( visible: hasClappedRecently, // aligns the top-center of `child` with the bottom-center of `portal` anchor: const Aligned( target: Alignment.topCenter, - source: Alignment.bottomCenter, + follower: Alignment.bottomCenter, ), closeDuration: kThemeChangeDuration, - portal: TweenAnimationBuilder( + portalFollower: TweenAnimationBuilder( tween: Tween(begin: 0, end: hasClappedRecently ? 1 : 0), duration: kThemeChangeDuration, builder: (context, progress, child) { diff --git a/example/lib/modal.dart b/example/lib/modal.dart index 3606080..d1d37a8 100644 --- a/example/lib/modal.dart +++ b/example/lib/modal.dart @@ -59,10 +59,10 @@ class Modal extends StatelessWidget { return Barrier( visible: visible, onClose: onClose, - child: PortalEntry( + child: PortalTarget( visible: visible, closeDuration: kThemeAnimationDuration, - portal: TweenAnimationBuilder( + portalFollower: TweenAnimationBuilder( duration: kThemeAnimationDuration, curve: Curves.easeOut, tween: Tween(begin: 0, end: visible ? 1 : 0), @@ -97,10 +97,10 @@ class Barrier extends StatelessWidget { @override Widget build(BuildContext context) { - return PortalEntry( + return PortalTarget( visible: visible, closeDuration: kThemeAnimationDuration, - portal: GestureDetector( + portalFollower: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onClose, child: TweenAnimationBuilder( diff --git a/example/lib/rounded_corners.dart b/example/lib/rounded_corners.dart new file mode 100644 index 0000000..c9ee200 --- /dev/null +++ b/example/lib/rounded_corners.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Portal( + child: MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Example'), + ), + body: const Padding( + padding: EdgeInsets.all(16), + child: RoundedCornersExample(), + ), + ), + ), + ); + } +} + +class RoundedCornersExample extends StatefulWidget { + const RoundedCornersExample({Key? key}) : super(key: key); + + @override + _RoundedCornersExampleState createState() => _RoundedCornersExampleState(); +} + +class _RoundedCornersExampleState extends State { + bool _showPopup = false; + + @override + Widget build(BuildContext context) { + return _ModalEntry( + visible: _showPopup, + onClose: () => setState(() => _showPopup = false), + popup: _Popup( + children: [ + for (var i = 0; i < 12; i++) + ListTile( + onTap: () => setState(() => _showPopup = false), + title: Text('$i'), + ), + ], + ), + child: ElevatedButton( + onPressed: () => setState(() => _showPopup = true), + child: const Text('show popup'), + ), + ); + } +} + +class _Popup extends StatelessWidget { + const _Popup({ + Key? key, + required this.children, + }) : super(key: key); + + final List children; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + bottom: 16, + ), + child: Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: IntrinsicWidth( + child: ListView( + shrinkWrap: true, + children: children, + ), + ), + ), + ); + } +} + +class _ModalEntry extends StatelessWidget { + const _ModalEntry({ + Key? key, + required this.onClose, + required this.visible, + required this.popup, + required this.child, + }) : super(key: key); + + final VoidCallback onClose; + final bool visible; + final Widget popup; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: visible ? onClose : null, + child: PortalTarget( + visible: visible, + portalFollower: popup, + // todo: implement anchor that sizes the follower based on the available space within the portal at the calculated offset. + anchor: const Aligned( + follower: Alignment.topLeft, + target: Alignment.bottomLeft, + widthFactor: 1, + ), + child: IgnorePointer( + ignoring: visible, + child: child, + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 7da7af8..3e075e0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -7,7 +7,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.6.1" + version: "2.8.2" boolean_selector: dependency: transitive description: @@ -21,14 +21,14 @@ packages: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.1" clock: dependency: transitive description: @@ -80,21 +80,28 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10" + version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.7.0" path: dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" sky_engine: dependency: transitive description: flutter @@ -106,7 +113,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.2" stack_trace: dependency: transitive description: @@ -141,7 +148,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.4.9" typed_data: dependency: transitive description: @@ -155,7 +162,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.2" sdks: - dart: ">=2.12.0 <3.0.0" + dart: ">=2.16.0 <3.0.0" flutter: ">=1.21.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 34a0b87..4d0d66b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,22 +1,10 @@ name: example -description: A new Flutter project. - +description: Example app demonstrating usage of flutter_portal. publish_to: none - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 1.0.0+1 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.16.0 <3.0.0' dependencies: # The following adds the Cupertino Icons font to your application. diff --git a/lib/flutter_portal.dart b/lib/flutter_portal.dart index 819adda..cfb1c59 100644 --- a/lib/flutter_portal.dart +++ b/lib/flutter_portal.dart @@ -1,2 +1,3 @@ -export 'src/anchor.dart' show Anchor, Aligned; -export 'src/portal.dart' show Portal, PortalEntry, PortalNotFoundError; +export 'package:flutter_portal/src/anchor.dart' show Anchor, Aligned; +export 'package:flutter_portal/src/portal.dart' + show Portal, PortalTarget, PortalNotFoundError; diff --git a/lib/src/anchor.dart b/lib/src/anchor.dart index 943cc3d..3effc78 100644 --- a/lib/src/anchor.dart +++ b/lib/src/anchor.dart @@ -1,67 +1,97 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; -/// The logic of layout and positioning of a source element in relation to a +/// The logic of layout and positioning of a follower element in relation to a /// target element. /// -/// Independent of the underlying rendering implementation. +/// This is independent of the underlying rendering implementation. abstract class Anchor { - /// Return the layout constraints that are given to the source element given: - /// - [targetRect] the bounds of the element which the source element should - /// be anchored to. No assumptions should be made about the coordinate space. - /// - [overlayConstraints] the available space to render the source element - BoxConstraints getSourceConstraints({ - required Rect targetRect, - required BoxConstraints overlayConstraints, + const Anchor(); + + /// Returns the layout constraints that are given to the follower element. + /// + /// The [targetSize] represents the bounds of the element which the follower + /// element should be anchored to. This must be the same value that is passed + /// to [getFollowerOffset]. No assumptions should be made about the coordinate + /// space, i.e. only the size of the target should be considered. + /// + /// The [portalConstraints] represent the full available space to place the + /// follower element in. This is irrespective of where the target is + /// positioned within the full available space. + BoxConstraints getFollowerConstraints({ + required Size targetSize, + required BoxConstraints portalConstraints, }); - /// Return the offset at which to position the source element in relation to - /// to the top-left corner of [targetRect] given: - /// - [sourceSize] the final calculated size of the source element - /// - [targetRect] the bounds of the element which the source should be - /// anchored to. Should be the same value passed in from [getSourceConstraints] - /// - [overlayRect] the bounds of the full available space to render the - /// source element - Offset getSourceOffset({ - required Size sourceSize, - required Rect targetRect, - required Rect overlayRect, + /// Returns the offset at which to position the follower element in relation + /// to the top left of the [targetSize]. + /// + /// The [followerSize] is the final size of the follower element after layout + /// based on the follower constraints determined by [getFollowerConstraints]. + /// + /// The [targetSize] represents the bounds of the element which the follower + /// element should be anchored to. This must be the same value that is passed + /// to [getFollowerConstraints]. + /// + /// The [portalRect] represents the bounds of the full available space to + /// place the follower element in. Note that this is also relative to the top + /// left of the [targetSize]. + /// This means that every offset going into or coming out of this function is + /// relative to the top-left corner of the target. + /// + /// ## Example + /// + /// In this example, our follower element has a size of `Size(30, 30)` and + /// should be anchored to the bottom right of the target. + /// + /// If we assume the full available space starts at absolute `(0, 0)` and + /// spans to absolute `(100, 100)` and the target rect starts at absolute + /// `(40, 40)` and spans to absolute `(60, 60)`, the passed values will be: + /// + /// * `Rect.fromLTWH(0, 0, 20, 20)` for the [targetSize]. + /// * `Rect.fromLTWH(-40, -40, 100, 100)` for the [portalRect]. + /// * `Size(30, 30)` for the [followerSize]. + /// * `Offset(20, 20)` as the return value. + Offset getFollowerOffset({ + required Size followerSize, + required Size targetSize, + required Rect portalRect, }); } -/// The source element should ignore any information about the target and expand -/// to fill the bounds of the overlay +/// The follower element should ignore any information about the target and +/// expand to fill the bounds of the overlay @immutable class Filled implements Anchor { const Filled(); @override - BoxConstraints getSourceConstraints({ - required Rect targetRect, - required BoxConstraints overlayConstraints, + BoxConstraints getFollowerConstraints({ + required Size targetSize, + required BoxConstraints portalConstraints, }) { - return BoxConstraints.tight(overlayConstraints.biggest); + return BoxConstraints.tight(portalConstraints.biggest); } @override - Offset getSourceOffset({ - required Size sourceSize, - required Rect targetRect, - required Rect overlayRect, + Offset getFollowerOffset({ + required Size followerSize, + required Size targetSize, + required Rect portalRect, }) { return Offset.zero; } } -/// Align a point of the source element with a point on the target element -/// Can optionally pass a [widthFactor] or [heightFactor] so the source element -/// gets a size as a factor of the target element. +/// Align a point of the follower element with a point on the target element +/// Can optionally pass a [widthFactor] or [heightFactor] so the follower +/// element gets a size as a factor of the target element. /// Can optionally pass a [backup] which will be used if the element is going /// to be rendered off screen. @immutable class Aligned implements Anchor { const Aligned({ - required this.source, + required this.follower, required this.target, this.offset = Offset.zero, this.widthFactor, @@ -70,73 +100,75 @@ class Aligned implements Anchor { }); static const center = Aligned( - source: Alignment.center, + follower: Alignment.center, target: Alignment.center, ); - /// The reference point on the source element - final Alignment source; + /// The reference point on the follower element. + final Alignment follower; /// The reference point on the target element final Alignment target; - /// Offset to shift the source element by after all calculations are made + /// Offset to shift the follower element by after all calculations are made. final Offset offset; - /// The width to make the source element as a multiple of the width of the - /// target element. An autocomplete widget may set this to 1 so the popup - /// width matches the the text field width + /// The width to make the follower element as a multiple of the width of the + /// target element. + /// + /// An autocomplete widget may set this to 1 so the popup width matches the + /// text field width. final double? widthFactor; - /// The height to make the source element as a multiple of the height of the + /// The height to make the follower element as a multiple of the height of the /// target element. final double? heightFactor; - /// If the calculated position would render the source element out of bounds + /// If the calculated position would render the follower element out of bounds /// (for example, a tooltip would go off screen), a backup can be used. /// The offset calculations will fall back to the backup. final Anchor? backup; @override - BoxConstraints getSourceConstraints({ - required Rect targetRect, - required BoxConstraints overlayConstraints, + BoxConstraints getFollowerConstraints({ + required Size targetSize, + required BoxConstraints portalConstraints, }) { final widthFactor = this.widthFactor; final heightFactor = this.heightFactor; - return overlayConstraints.loosen().tighten( - width: widthFactor == null ? null : targetRect.width * widthFactor, + return portalConstraints.loosen().tighten( + width: widthFactor == null ? null : targetSize.width * widthFactor, height: - heightFactor == null ? null : targetRect.height * heightFactor, + heightFactor == null ? null : targetSize.height * heightFactor, ); } @override - Offset getSourceOffset({ - required Size sourceSize, - required Rect targetRect, - required Rect overlayRect, + Offset getFollowerOffset({ + required Size followerSize, + required Size targetSize, + required Rect portalRect, }) { - final sourceRect = (Offset.zero & sourceSize).alignedTo( - targetRect, - sourceAlignment: source, + final followerRect = followerSize.alignedTo( + targetSize, + followerAlignment: follower, targetAlignment: target, offset: offset, ); - if (!overlayRect.fullyContains(sourceRect)) { + if (!portalRect.fullyContains(followerRect)) { final backup = this.backup; if (backup != null) { - return backup.getSourceOffset( - sourceSize: sourceSize, - targetRect: targetRect, - overlayRect: overlayRect, + return backup.getFollowerOffset( + followerSize: followerSize, + targetSize: targetSize, + portalRect: portalRect, ); } } - return sourceRect.topLeft; + return followerRect.topLeft; } @override @@ -147,33 +179,51 @@ class Aligned implements Anchor { if (other is! Aligned) { return false; } - return source == other.source && + return follower == other.follower && target == other.target && offset == other.offset && backup == other.backup; } @override - int get hashCode => source.hashCode ^ target.hashCode ^ offset.hashCode; + int get hashCode => Object.hash(follower, target, offset, backup); } -extension _RectAnchorExt on Rect { +extension on Size { + /// Returns a [Rect] that is aligned to the sizes (follower size / this and + /// the target size) along the given alignments, shifted by [offset]. Rect alignedTo( - Rect target, { - required Alignment sourceAlignment, + Size targetSize, { + required Alignment followerAlignment, required Alignment targetAlignment, Offset offset = Offset.zero, }) { - final sourceOffset = targetAlignment.alongSize(target.size) - - sourceAlignment.alongSize(size) + - target.topLeft + + final followerOffset = targetAlignment.alongSize(targetSize) - + followerAlignment.alongSize(this) + offset; - return sourceOffset & size; + return followerOffset & this; } +} +extension on Rect { /// Returns true if [rect] is fully contained within this rect /// If the [rect] has any part that lies outside of this parent /// false will be returned bool fullyContains(Rect rect) => - contains(rect.topLeft) && contains(rect.bottomRight); + containsIncludingBottomAndRightEdge(rect.topLeft) && + containsIncludingBottomAndRightEdge(rect.bottomRight); + + /// Whether the point specified by the given offset (which is assumed to be + /// relative to the origin) lies between the left and right and the top and + /// bottom edges of this rectangle. + /// + /// This is like [contains] but also includes the bottom edge and the right + /// edge because in the context of painting, it would make no sense to + /// consider a rect as overflowing when it lines up exactly with another rect. + bool containsIncludingBottomAndRightEdge(Offset offset) { + return offset.dx >= left && + offset.dx <= right && + offset.dy >= top && + offset.dy <= bottom; + } } diff --git a/lib/src/custom_follower.dart b/lib/src/custom_follower.dart index 82f5626..4c0f12e 100644 --- a/lib/src/custom_follower.dart +++ b/lib/src/custom_follower.dart @@ -1,13 +1,17 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'anchor.dart'; import 'portal.dart'; /// @nodoc -class MyCompositedTransformFollower extends SingleChildRenderObjectWidget { +class CustomCompositedTransformFollower extends SingleChildRenderObjectWidget { /// @nodoc - const MyCompositedTransformFollower({ + const CustomCompositedTransformFollower({ Key? key, required this.link, required this.overlayLink, @@ -29,8 +33,8 @@ class MyCompositedTransformFollower extends SingleChildRenderObjectWidget { final Size targetSize; @override - MyRenderFollowerLayer createRenderObject(BuildContext context) { - return MyRenderFollowerLayer( + CustomRenderFollowerLayer createRenderObject(BuildContext context) { + return CustomRenderFollowerLayer( anchor: anchor, link: link, overlayLink: overlayLink, @@ -41,7 +45,7 @@ class MyCompositedTransformFollower extends SingleChildRenderObjectWidget { @override void updateRenderObject( BuildContext context, - MyRenderFollowerLayer renderObject, + CustomRenderFollowerLayer renderObject, ) { renderObject ..link = link @@ -62,9 +66,10 @@ class MyCompositedTransformFollower extends SingleChildRenderObjectWidget { } /// @nodoc -class MyRenderFollowerLayer extends RenderProxyBox { +@visibleForTesting +class CustomRenderFollowerLayer extends RenderProxyBox { /// @nodoc - MyRenderFollowerLayer({ + CustomRenderFollowerLayer({ required LayerLink link, required OverlayLink overlayLink, required Size targetSize, @@ -80,6 +85,7 @@ class MyRenderFollowerLayer extends RenderProxyBox { /// @nodoc Anchor get anchor => _anchor; + set anchor(Anchor value) { if (_anchor != value) { _anchor = value; @@ -91,6 +97,7 @@ class MyRenderFollowerLayer extends RenderProxyBox { /// @nodoc LayerLink get link => _link; + set link(LayerLink value) { if (_link == value) { return; @@ -100,7 +107,9 @@ class MyRenderFollowerLayer extends RenderProxyBox { } OverlayLink _overlayLink; + OverlayLink get overlayLink => _overlayLink; + set overlayLink(OverlayLink value) { if (_overlayLink == value) { return; @@ -113,6 +122,7 @@ class MyRenderFollowerLayer extends RenderProxyBox { /// @nodoc Size get targetSize => _targetSize; + set targetSize(Size value) { if (_targetSize == value) { return; @@ -131,10 +141,7 @@ class MyRenderFollowerLayer extends RenderProxyBox { bool get alwaysNeedsCompositing => true; @override - bool get sizedByParent => false; - - @override - FollowerLayer? get layer => super.layer as FollowerLayer?; + _CustomFollowerLayer? get layer => super.layer as _CustomFollowerLayer?; /// @nodoc Matrix4 getCurrentTransform() { @@ -157,36 +164,52 @@ class MyRenderFollowerLayer extends RenderProxyBox { ); } - @override - void performResize() { - size = constraints.biggest; + /// Returns the linked offset in relation to the leader layer. + /// + /// The [LeaderLayer] is inserted by the [CompositedTransformTarget] in + /// [PortalTarget]. + /// + /// The reason we cannot simply access the [link]'s leader in [paint] is that + /// the leader is only attached to the [LayerLink] in [LeaderLayer.attach], + /// which is called in the compositing phase which is after the paint phase. + Offset _computeLinkedOffset(Offset leaderOffset) { + assert( + overlayLink.theater != null, + 'The theater must be set in the OverlayLink when the ' + '_RenderPortalTheater is inserted as a child of the _PortalLinkScope. ' + 'Therefore, it must not be null in any child PortalEntry.', + ); + final theater = overlayLink.theater!; + + // In order to compute the theater rect, we must first offset (shift) it by + // the position of the top-left corner of the target in the coordinate space + // of the theater since we are working with it relative to the target. + final theaterShift = -globalToLocal( + leaderOffset, + ancestor: theater, + ); + + final theaterRect = theaterShift & theater.size; + + return anchor.getFollowerOffset( + // The size is set in performLayout of the RenderProxyBoxMixin. + followerSize: size, + targetSize: targetSize, + portalRect: theaterRect, + ); } @override void paint(PaintingContext context, Offset offset) { - final linkedOffset = anchor.getSourceOffset( - sourceSize: size, - targetRect: Rect.fromLTWH(0, 0, targetSize.width, targetSize.height), - overlayRect: const Rect.fromLTRB( - // We don't know where we'll end up, so we have no idea what our cull rect should be. - 0, - 0, - double.infinity, - double.infinity, - ), - ); - if (layer == null) { - layer = FollowerLayer( + layer = _CustomFollowerLayer( link: link, - showWhenUnlinked: false, - linkedOffset: linkedOffset, + linkedOffsetCallback: _computeLinkedOffset, ); } else { layer! ..link = link - ..showWhenUnlinked = false - ..linkedOffset = linkedOffset; + ..linkedOffsetCallback = _computeLinkedOffset; } context.pushLayer( @@ -221,3 +244,225 @@ class MyRenderFollowerLayer extends RenderProxyBox { properties.add(DiagnosticsProperty('targetSize', targetSize)); } } + +/// A composited layer that applies a transformation matrix to its children such +/// that they are positioned based on the position of a [LeaderLayer] and some +/// extra computation performed by a callback. +/// +/// Note that this is like [FollowerLayer] but instead of taking a +/// [FollowerLayer.linkedOffset], it takes a [linkedOffsetCallback] to compute +/// this offset. +/// +/// This custom follower layer does not do anything if unlinked (equal to +/// [FollowerLayer.unlinkedOffset] being [Offset.zero]). +/// +/// For documentation of undocumented code, see [FollowerLayer]. +class _CustomFollowerLayer extends ContainerLayer { + /// Creates a follower layer. + /// + /// The [link] property must not be null. + _CustomFollowerLayer({ + required this.link, + required this.linkedOffsetCallback, + }); + + LayerLink link; + + /// Callback that is called to compute the linked offset of the follower layer + /// based on the `leaderOffset` of the leader layer. + /// + /// This is like [FollowerLayer.linkedOffset] but as a callback. Note that + /// this has the *exact* function of [FollowerLayer.linkedOffset] and + /// therefore the leader layer offset does not need to be added to the + /// returned offset. The returned offset should only be the offset from the + /// leader layer. + /// The `leaderOffset` is only passed in case it needs to be used inside of + /// the callback for computation reasons. + Offset Function(Offset leaderOffset) linkedOffsetCallback; + + Offset? _lastOffset; + Matrix4? _lastTransform; + Matrix4? _invertedTransform; + bool _inverseDirty = true; + + Offset? _transformOffset(Offset localPosition) { + if (_inverseDirty) { + _invertedTransform = Matrix4.tryInvert(getLastTransform()!); + _inverseDirty = false; + } + if (_invertedTransform == null) { + return null; + } + final vector = Vector4(localPosition.dx, localPosition.dy, 0, 1); + final result = _invertedTransform!.transform(vector); + // We know the link leader cannot be null since we return early in + // findAnnotations otherwise. + final linkedOffset = linkedOffsetCallback(link.leader!.offset); + return Offset(result[0] - linkedOffset.dx, result[1] - linkedOffset.dy); + } + + @override + bool findAnnotations( + AnnotationResult result, Offset localPosition, + {required bool onlyFirst}) { + if (link.leader == null) { + return false; + } + final transformedOffset = _transformOffset(localPosition); + if (transformedOffset == null) { + return false; + } + return super + .findAnnotations(result, transformedOffset, onlyFirst: onlyFirst); + } + + Matrix4? getLastTransform() { + if (_lastTransform == null) { + return null; + } + final result = + Matrix4.translationValues(-_lastOffset!.dx, -_lastOffset!.dy, 0); + result.multiply(_lastTransform!); + return result; + } + + static Matrix4 _collectTransformForLayerChain(List layers) { + // Initialize our result matrix. + final result = Matrix4.identity(); + // Apply each layer to the matrix in turn, starting from the last layer, + // and providing the previous layer as the child. + for (var index = layers.length - 1; index > 0; index -= 1) { + layers[index]?.applyTransform(layers[index - 1], result); + } + return result; + } + + static Layer? _pathsToCommonAncestor( + Layer? a, + Layer? b, + List ancestorsA, + List ancestorsB, + ) { + // No common ancestor found. + if (a == null || b == null) { + return null; + } + + if (identical(a, b)) { + return a; + } + + if (a.depth < b.depth) { + ancestorsB.add(b.parent); + return _pathsToCommonAncestor(a, b.parent, ancestorsA, ancestorsB); + } else if (a.depth > b.depth) { + ancestorsA.add(a.parent); + return _pathsToCommonAncestor(a.parent, b, ancestorsA, ancestorsB); + } + + ancestorsA.add(a.parent); + ancestorsB.add(b.parent); + return _pathsToCommonAncestor(a.parent, b.parent, ancestorsA, ancestorsB); + } + + void _establishTransform() { + _lastTransform = null; + final leader = link.leader; + // Check to see if we are linked. + if (leader == null) { + return; + } + // If we're linked, check the link is valid. + assert( + leader.owner == owner, + 'Linked LeaderLayer anchor is not in the same layer tree as the FollowerLayer.', + ); + + // Stores [leader, ..., commonAncestor] after calling _pathsToCommonAncestor. + final List forwardLayers = [leader]; + // Stores [this (follower), ..., commonAncestor] after calling + // _pathsToCommonAncestor. + final List inverseLayers = [this]; + + final ancestor = _pathsToCommonAncestor( + leader, + this, + forwardLayers, + inverseLayers, + ); + assert(ancestor != null); + + final forwardTransform = _collectTransformForLayerChain(forwardLayers); + // Further transforms the coordinate system to a hypothetical child (null) + // of the leader layer, to account for the leader's additional paint offset + // and layer offset (LeaderLayer._lastOffset). + leader.applyTransform(null, forwardTransform); + final linkedOffset = linkedOffsetCallback(leader.offset); + forwardTransform.translate(linkedOffset.dx, linkedOffset.dy); + + final inverseTransform = _collectTransformForLayerChain(inverseLayers); + + if (inverseTransform.invert() == 0.0) { + // We are in a degenerate transform, so there's not much we can do. + return; + } + // Combine the matrices and store the result. + inverseTransform.multiply(forwardTransform); + _lastTransform = inverseTransform; + _inverseDirty = true; + } + + @override + bool get alwaysNeedsAddToScene => true; + + @override + void addToScene(SceneBuilder builder, [Offset layerOffset = Offset.zero]) { + if (link.leader == null) { + _lastTransform = null; + _lastOffset = null; + _inverseDirty = true; + engineLayer = null; + return; + } + _establishTransform(); + if (_lastTransform != null) { + engineLayer = builder.pushTransform( + _lastTransform!.storage, + oldLayer: engineLayer as TransformEngineLayer?, + ); + addChildrenToScene(builder); + builder.pop(); + _lastOffset = layerOffset; + } else { + _lastOffset = null; + final matrix = Matrix4.translationValues(0, 0, 0); + engineLayer = builder.pushTransform( + matrix.storage, + oldLayer: engineLayer as TransformEngineLayer?, + ); + addChildrenToScene(builder); + builder.pop(); + } + _inverseDirty = true; + } + + @override + void applyTransform(Layer? child, Matrix4 transform) { + assert(child != null); + if (_lastTransform != null) { + transform.multiply(_lastTransform!); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('link', link)); + properties.add( + TransformProperty('transform', getLastTransform(), defaultValue: null)); + properties.add(DiagnosticsProperty( + 'linkedOffsetCallback', + linkedOffsetCallback, + )); + } +} diff --git a/lib/src/portal.dart b/lib/src/portal.dart index 09dfb92..d8e08ac 100644 --- a/lib/src/portal.dart +++ b/lib/src/portal.dart @@ -3,21 +3,20 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; import 'anchor.dart'; import 'custom_follower.dart'; -/// The widget where a [PortalEntry] is rendered. +/// The widget where a [PortalTarget] and its [PortalFollower] are rendered. /// /// [Portal] can be considered as a reimplementation of [Overlay] to allow -/// adding an [OverlayEntry] (now named [PortalEntry]) declaratively. +/// adding an [OverlayEntry] (now named [PortalTarget]) declaratively. /// -/// [Portal] widget is used in co-ordination with [PortalEntry] widget to show -/// some content _above_ another content. -/// This is similar to [Stack] in principle, with the difference that [PortalEntry] -/// does not have to be a direct child of [Portal] and can instead be placed -/// anywhere in the widget tree. +/// The [Portal] widget is used in coordination with the [PortalTarget] widget +/// to show some content _above_ other content. +/// This is similar to [Stack] in principle, with the difference that a +/// [PortalTarget] does not have to be a direct child of [Portal] and can +/// instead be placed anywhere in the widget tree. /// /// In most situations, [Portal] can be placed directly above [MaterialApp]: /// @@ -28,7 +27,7 @@ import 'custom_follower.dart'; /// ); /// ``` /// -/// This allows an overlay to renders above _everything_ including all routes. +/// This allows an overlay to render above _everything_ including all routes. /// That can be useful to show a snackbar between pages. /// /// You can optionally add a [Portal] inside your page: @@ -68,6 +67,7 @@ class _PortalState extends State { class OverlayLink { _RenderPortalTheater? theater; + BoxConstraints? get constraints => theater?.constraints; final Set overlays = {}; @@ -119,7 +119,9 @@ class _RenderPortalTheater extends RenderProxyBox { } OverlayLink _overlayLink; + OverlayLink get overlayLink => _overlayLink; + set overlayLink(OverlayLink value) { if (_overlayLink != value) { assert( @@ -151,8 +153,9 @@ class _RenderPortalTheater extends RenderProxyBox { @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final globalPosition = localToGlobal(position); // #42 for (final overlay in overlayLink.overlays) { - if (overlay.hitTest(result, position: position)) { + if (overlay.hitTest(result, position: globalPosition /* #42 */)) { return true; } } @@ -169,26 +172,47 @@ class _RenderPortalTheater extends RenderProxyBox { } } -/// A widget that renders its content in a different location of the widget tree. +/// Widget that is passed to a [PortalTarget] as the follower that is overlaid +/// on top of other content in a [Portal]. +/// +/// This is just a regular [Widget] that is passed as +/// [PortalTarget.portalFollower]. The target takes care of making it a +/// follower → it is only a typedef. +typedef PortalFollower = Widget; + +// todo(creativecreatorormaybenot): update target docs. + +/// A widget that renders its follower in a different location of the widget +/// tree. /// -/// In short, you can use [PortalEntry] to show dialogs, tooltips, contextual menus, ... -/// You can then control the visibility of these overlays with a simple `setState`. +/// Its [child] is rendered in the tree as you would expect, but its +/// [portalFollower] is rendered through the ancestor [Portal] in a different +/// location of the widget tree. /// -/// The benefits of using [PortalEntry] over [Overlay]/[OverlayEntry] are multiple: -/// - [PortalEntry] is easier to manipulate +/// In short, you can use [PortalTarget] to show dialogs, tooltips, contextual +/// menus, etc. +/// You can then control the visibility of these overlays with a simple +/// `setState`. +/// +/// The benefits of using [PortalTarget]/[PortalFollower] over +/// [Overlay]/[OverlayEntry] are multiple: +/// - [PortalTarget] is easier to manipulate /// - It allows aligning your menus/tooltips next to a button easily -/// - It combines nicely with state-management solutions and the "state-restoration" -/// framework. For example, combined with [RestorableProperty], when the application -/// is killed then re-opened, modals/menus would be restored. +/// - It combines nicely with state-management solutions and the +/// "state-restoration" framework. For example, combined with +/// [RestorableProperty] when the application is killed then re-opened, +/// modals/menus would be restored. /// -/// For [PortalEntry] to work, make sure to insert [Portal] higher in the widget-tree. +/// For [PortalTarget] to work, make sure to insert [Portal] higher in the +/// widget tree. /// /// ## Contextual menu example /// -/// In this example, we will see how we can use [PortalEntry] to show a menu +/// In this example, we will see how we can use [PortalTarget] to show a menu /// after clicking on a [ElevatedButton]. /// -/// First, we need to create a [StatefulWidget] that renders our [ElevatedButton]: +/// First, we need to create a [StatefulWidget] that renders our +/// [ElevatedButton]: /// /// ```dart /// class MenuExample extends StatefulWidget { @@ -211,16 +235,17 @@ class _RenderPortalTheater extends RenderProxyBox { /// } /// ``` /// -/// Then, we need to insert our [PortalEntry] in the widget tree. +/// Then, we need to insert our [PortalTarget] in the widget tree. /// /// We want our contextual menu to render right next to our [ElevatedButton]. -/// As such, our [PortalEntry] should be the parent of [ElevatedButton] like so: +/// As such, our [PortalTarget] should be the parent of [ElevatedButton] like +/// so: /// /// ```dart /// Center( -/// child: PortalEntry( +/// child: PortalTarget( /// visible: // -/// portal: // +/// portalFollower: // /// child: ElevatedButton( /// ... /// ), @@ -228,13 +253,13 @@ class _RenderPortalTheater extends RenderProxyBox { /// ) /// ``` /// -/// We can pass our menu to [PortalEntry]: +/// We can pass our menu as the `portalFollower` to [PortalTarget]: /// /// /// ```dart -/// PortalEntry( +/// PortalTarget( /// visible: true, -/// portal: Material( +/// portalFollower: Material( /// elevation: 8, /// child: IntrinsicWidth( /// child: Column( @@ -246,7 +271,7 @@ class _RenderPortalTheater extends RenderProxyBox { /// ), /// ), /// ), -/// child: RaiseButton(...), +/// child: ElevatedButton(...), /// ) /// ``` /// @@ -258,22 +283,24 @@ class _RenderPortalTheater extends RenderProxyBox { /// Let's fix the full-screen issue first and change our code so that our /// menu renders on the _right_ of our [ElevatedButton]. /// -/// To align our menu around our button, we can specify the `childAnchor` and -/// `portalAnchor` parameters: +/// To align our menu around our button, we can specify the `anchor` +/// parameter: /// /// ```dart /// PortalEntry( /// visible: true, -/// portalAnchor: Alignment.topLeft, -/// childAnchor: Alignment.topRight, -/// portal: Material(...), -/// child: RaiseButton(...), +/// anchor: const Aligned( +/// follower: Alignment.topLeft, +/// target: Alignment.topRight, +/// ), +/// portalFollower: Material(...), +/// child: ElevatedButton(...), /// ) /// ``` /// /// What this code means is, this will align the top-left of our menu with the /// top-right or the [ElevatedButton]. -/// With this, our menu is no-longer full-screen and is now located to the right +/// With this, our menu is no longer full-screen and is now located to the right /// of our button. /// /// Finally, we can update our code such that the menu show only when clicking @@ -292,7 +319,7 @@ class _RenderPortalTheater extends RenderProxyBox { /// We then pass this `isMenuOpen` variable to our [PortalEntry]: /// /// ```dart -/// PortalEntry( +/// PortalTarget( /// visible: isMenuOpen, /// ... /// ) @@ -316,15 +343,15 @@ class _RenderPortalTheater extends RenderProxyBox { /// One final step is to close the menu when the user clicks randomly outside /// of the menu. /// -/// This can be implemented with a second [PortalEntry] combined with [GestureDetector] +/// This can be implemented with a second [PortalTarget] combined with [GestureDetector] /// like so: /// /// /// ```dart /// Center( -/// child: PortalEntry( +/// child: PortalTarget( /// visible: isMenuOpen, -/// portal: GestureDetector( +/// portalFollower: GestureDetector( /// behavior: HitTestBehavior.opaque, /// onTap: () { /// setState(() { @@ -332,34 +359,34 @@ class _RenderPortalTheater extends RenderProxyBox { /// }); /// }, /// ), -/// child: PortalEntry( -/// // our previous PortalEntry -/// portal: Material(...) +/// child: PortalTarget( +/// // our previous PortalTarget +/// portalFollower: Material(...) /// child: ElevatedButton(...), /// ), /// ), /// ) /// ``` -class PortalEntry extends StatefulWidget { - const PortalEntry({ +class PortalTarget extends StatefulWidget { + const PortalTarget({ Key? key, this.visible = true, this.anchor = const Filled(), - this.portal, this.closeDuration, + this.portalFollower, required this.child, - }) : assert(visible == false || portal != null), + }) : assert(visible == false || portalFollower != null), super(key: key); // ignore: diagnostic_describe_all_properties, conflicts with closeDuration final bool visible; final Anchor anchor; - final Widget? portal; - final Widget child; final Duration? closeDuration; + final PortalFollower? portalFollower; + final Widget child; @override - _PortalEntryState createState() => _PortalEntryState(); + _PortalTargetState createState() => _PortalTargetState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -367,18 +394,18 @@ class PortalEntry extends StatefulWidget { properties ..add(DiagnosticsProperty('anchor', anchor)) ..add(DiagnosticsProperty('closeDuration', closeDuration)) - ..add(DiagnosticsProperty('portal', portal)) + ..add(DiagnosticsProperty('portalFollower', portalFollower)) ..add(DiagnosticsProperty('child', child)); } } -class _PortalEntryState extends State { +class _PortalTargetState extends State { final _link = LayerLink(); late bool _visible = widget.visible; Timer? _timer; @override - void didUpdateWidget(PortalEntry oldWidget) { + void didUpdateWidget(PortalTarget oldWidget) { super.didUpdateWidget(oldWidget); if (!widget.visible) { if (!oldWidget.visible && _visible) { @@ -407,8 +434,8 @@ class _PortalEntryState extends State { } if (widget.anchor is Filled) { - return _PortalEntryTheater( - portal: _visible ? widget.portal : null, + return _PortalTargetTheater( + portalFollower: _visible ? widget.portalFollower : null, anchor: widget.anchor, targetSize: Size.zero, overlayLink: scope._overlayLink, @@ -428,16 +455,16 @@ class _PortalEntryState extends State { builder: (context, constraints) { final targetSize = constraints.biggest; - return _PortalEntryTheater( + return _PortalTargetTheater( overlayLink: scope._overlayLink, anchor: widget.anchor, targetSize: targetSize, - portal: MyCompositedTransformFollower( + portalFollower: CustomCompositedTransformFollower( link: _link, overlayLink: scope._overlayLink, anchor: widget.anchor, targetSize: targetSize, - child: widget.portal, + child: widget.portalFollower, ), child: const SizedBox.shrink(), ); @@ -455,24 +482,24 @@ class _PortalEntryState extends State { } } -class _PortalEntryTheater extends SingleChildRenderObjectWidget { - const _PortalEntryTheater({ +class _PortalTargetTheater extends SingleChildRenderObjectWidget { + const _PortalTargetTheater({ Key? key, - required this.portal, + required this.portalFollower, required this.overlayLink, required this.anchor, required this.targetSize, required Widget child, }) : super(key: key, child: child); - final Widget? portal; + final Widget? portalFollower; final Anchor anchor; final OverlayLink overlayLink; final Size targetSize; @override RenderObject createRenderObject(BuildContext context) { - return _RenderPortalEntry( + return _RenderPortalTarget( overlayLink, anchor: anchor, targetSize: targetSize, @@ -482,7 +509,7 @@ class _PortalEntryTheater extends SingleChildRenderObjectWidget { @override void updateRenderObject( BuildContext context, - _RenderPortalEntry renderObject, + _RenderPortalTarget renderObject, ) { renderObject ..overlayLink = overlayLink @@ -491,7 +518,8 @@ class _PortalEntryTheater extends SingleChildRenderObjectWidget { } @override - SingleChildRenderObjectElement createElement() => _PortalEntryElement(this); + SingleChildRenderObjectElement createElement() => _PortalTargetElement(this); + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -503,17 +531,21 @@ class _PortalEntryTheater extends SingleChildRenderObjectWidget { } } -class _RenderPortalEntry extends RenderProxyBox { - _RenderPortalEntry(this._overlayLink, - {required Anchor anchor, required Size targetSize}) - : assert(_overlayLink.theater != null), +class _RenderPortalTarget extends RenderProxyBox { + _RenderPortalTarget( + this._overlayLink, { + required Anchor anchor, + required Size targetSize, + }) : assert(_overlayLink.theater != null), _anchor = anchor, _targetSize = targetSize; bool _needsAddEntryInTheater = false; OverlayLink _overlayLink; + OverlayLink get overlayLink => _overlayLink; + set overlayLink(OverlayLink value) { assert(value.theater != null); if (_overlayLink != value) { @@ -523,7 +555,9 @@ class _RenderPortalEntry extends RenderProxyBox { } Anchor _anchor; + Anchor get anchor => _anchor; + set anchor(Anchor value) { if (value != _anchor) { _anchor = value; @@ -532,7 +566,9 @@ class _RenderPortalEntry extends RenderProxyBox { } Size _targetSize; + Size get targetSize => _targetSize; + set targetSize(Size value) { if (value != _targetSize) { _targetSize = value; @@ -541,7 +577,9 @@ class _RenderPortalEntry extends RenderProxyBox { } RenderBox? _branch; + RenderBox? get branch => _branch; + set branch(RenderBox? value) { if (_branch != null) { _overlayLink.overlays.remove(branch); @@ -589,9 +627,9 @@ class _RenderPortalEntry extends RenderProxyBox { void performLayout() { super.performLayout(); if (branch != null) { - final constraints = anchor.getSourceConstraints( - overlayConstraints: overlayLink.constraints!, - targetRect: Offset.zero & targetSize, + final constraints = anchor.getFollowerConstraints( + portalConstraints: overlayLink.constraints!, + targetSize: targetSize, ); branch!.layout(constraints); if (_needsAddEntryInTheater) { @@ -605,7 +643,7 @@ class _RenderPortalEntry extends RenderProxyBox { @override void applyPaintTransform(RenderObject child, Matrix4 transform) { if (child == branch) { - // ignore all transformations applied between Portal and PortalEntry + // ignore all transformations applied between Portal and PortalTarget transform.setFrom(overlayLink.theater!.getTransformTo(null)); } } @@ -630,22 +668,22 @@ class _RenderPortalEntry extends RenderProxyBox { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - .add(DiagnosticsProperty('overlayLink', overlayLink)); - properties.add(DiagnosticsProperty('anchor', anchor)); - properties.add(DiagnosticsProperty('targetSize', targetSize)); - properties.add(DiagnosticsProperty('branch', branch)); + ..add(DiagnosticsProperty('overlayLink', overlayLink)) + ..add(DiagnosticsProperty('anchor', anchor)) + ..add(DiagnosticsProperty('targetSize', targetSize)) + ..add(DiagnosticsProperty('branch', branch)); } } -class _PortalEntryElement extends SingleChildRenderObjectElement { - _PortalEntryElement(_PortalEntryTheater widget) : super(widget); +class _PortalTargetElement extends SingleChildRenderObjectElement { + _PortalTargetElement(_PortalTargetTheater widget) : super(widget); @override - _PortalEntryTheater get widget => super.widget as _PortalEntryTheater; + _PortalTargetTheater get widget => super.widget as _PortalTargetTheater; @override - _RenderPortalEntry get renderObject => - super.renderObject as _RenderPortalEntry; + _RenderPortalTarget get renderObject => + super.renderObject as _RenderPortalTarget; Element? _branch; @@ -654,13 +692,13 @@ class _PortalEntryElement extends SingleChildRenderObjectElement { @override void mount(Element? parent, dynamic newSlot) { super.mount(parent, newSlot); - _branch = updateChild(_branch, widget.portal, _branchSlot); + _branch = updateChild(_branch, widget.portalFollower, _branchSlot); } @override void update(SingleChildRenderObjectWidget newWidget) { super.update(newWidget); - _branch = updateChild(_branch, widget.portal, _branchSlot); + _branch = updateChild(_branch, widget.portalFollower, _branchSlot); } @override @@ -711,16 +749,16 @@ class _PortalEntryElement extends SingleChildRenderObjectElement { } } -/// The error that will be thrown if [PortalEntry] fails to find a [Portal]. +/// The error that is thrown when a [PortalTarget] fails to find a [Portal]. class PortalNotFoundError extends Error { - PortalNotFoundError._(this._portalEntry); + PortalNotFoundError._(this._portalTarget); - final PortalEntry _portalEntry; + final PortalTarget _portalTarget; @override String toString() { return ''' -Error: Could not find a $T above this $_portalEntry. +Error: Could not find a $T above this $_portalTarget. '''; } } diff --git a/pubspec.yaml b/pubspec.yaml index 977d73f..bba6b1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,10 @@ name: flutter_portal version: 0.4.0 -description: Overlay/OverlayEntry, but implemented as a widget for a declarative API. +description: Overlay/OverlayEntry but implemented as a widget for a declarative API. homepage: https://github.com/rrousselGit/flutter_portal -authors: - - Remi Rousselet environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.16.0 <3.0.0' flutter: '>=1.21.0' dependencies: diff --git a/test/anchor_test.dart b/test/anchor_test.dart new file mode 100644 index 0000000..4e1ffda --- /dev/null +++ b/test/anchor_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_portal/src/anchor.dart'; +import 'package:flutter_portal/src/portal.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('$Anchor is passed proper constraints', (tester) async { + Size? constraintsTargetSize; + BoxConstraints? constraintsOverlayConstraints; + Size? offsetSourceSize; + Size? offsetTargetSize; + Rect? offsetTheaterRect; + final anchor = _TestAnchor( + constraints: const BoxConstraints.tightFor( + width: 42, + height: 42, + ), + onGetSourceConstraints: (targetSize, overlayConstraints) { + constraintsTargetSize = targetSize; + constraintsOverlayConstraints = overlayConstraints; + }, + onGetSourceOffset: (sourceSize, targetSize, theaterRect) { + offsetSourceSize = sourceSize; + offsetTargetSize = targetSize; + offsetTheaterRect = theaterRect; + }, + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: ColoredBox( + color: Colors.green, + child: Portal( + child: Center( + child: ColoredBox( + color: Colors.white, + child: SizedBox( + width: 50, + height: 50, + child: PortalTarget( + anchor: anchor, + portalFollower: const ColoredBox( + color: Colors.red, + ), + child: const Center( + child: ColoredBox( + color: Colors.black, + child: SizedBox( + width: 20, + height: 20, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + )); + + expect(constraintsTargetSize, const Size(50, 50)); + expect( + constraintsOverlayConstraints, + BoxConstraints.tight(const Size(100, 100)), + ); + expect(constraintsTargetSize, offsetTargetSize); + expect(offsetSourceSize, const Size(42, 42)); + expect(offsetTheaterRect, const Offset(-25, -25) & const Size(100, 100)); + }); + + testWidgets('$Aligned defers to backup if needed', (tester) async { + var offsetAccessed = false; + final backupAligned = _TestAligned( + source: Alignment.bottomLeft, + target: Alignment.topLeft, + onGetSourceOffset: () => offsetAccessed = true, + ); + final entry = PortalTarget( + anchor: Aligned( + follower: Alignment.topLeft, + target: Alignment.bottomLeft, + backup: backupAligned, + ), + portalFollower: const SizedBox( + width: 20, + height: 20, + ), + child: const SizedBox( + width: 10, + height: 10, + ), + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 50, + height: 50, + child: Portal( + child: Center( + child: entry, + ), + ), + ), + ), + ), + )); + + expect(offsetAccessed, false); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 50, + height: 49, + child: Portal( + child: Center( + child: entry, + ), + ), + ), + ), + ), + )); + + expect(offsetAccessed, true); + }); +} + +class _TestAnchor implements Anchor { + const _TestAnchor({ + required this.constraints, + required this.onGetSourceConstraints, + required this.onGetSourceOffset, + }); + + final BoxConstraints constraints; + + final void Function( + Size targetSize, + BoxConstraints overlayConstraints, + ) onGetSourceConstraints; + final void Function( + Size sourceSize, + Size targetSize, + Rect theaterRect, + ) onGetSourceOffset; + + @override + BoxConstraints getFollowerConstraints({ + required Size targetSize, + required BoxConstraints portalConstraints, + }) { + onGetSourceConstraints(targetSize, portalConstraints); + return constraints; + } + + @override + Offset getFollowerOffset({ + required Size followerSize, + required Size targetSize, + required Rect portalRect, + }) { + onGetSourceOffset(followerSize, targetSize, portalRect); + return Offset.zero; + } +} + +class _TestAligned extends Aligned { + const _TestAligned({ + required Alignment source, + required Alignment target, + Offset offset = Offset.zero, + double? widthFactor, + double? heightFactor, + required this.onGetSourceOffset, + }) : super( + follower: source, + target: target, + offset: offset, + widthFactor: widthFactor, + heightFactor: heightFactor); + + final VoidCallback onGetSourceOffset; + + @override + Offset getFollowerOffset({ + required Size followerSize, + required Size targetSize, + required Rect portalRect, + }) { + onGetSourceOffset(); + return super.getFollowerOffset( + followerSize: followerSize, + targetSize: targetSize, + portalRect: portalRect, + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 457129e..476535e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,21 +5,28 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_portal/src/anchor.dart'; import 'package:flutter_portal/src/portal.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; Future fetchFont() async { - final roboto = File.fromUri( - Uri.file('${Directory.current.path}/../assets/Roboto-Regular.ttf'), - ); + final Uri fontUri; + if (p.basename(Directory.current.path) == 'test') { + fontUri = + Uri.file('${Directory.current.path}/../assets/Roboto-Regular.ttf'); + } else { + // We assume the test command is executed from the project root. + fontUri = Uri.file('assets/Roboto-Regular.ttf'); + } + + final roboto = File.fromUri(fontUri); final bytes = Uint8List.fromList(await roboto.readAsBytes()); return ByteData.view(bytes.buffer); } @@ -37,9 +44,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -51,10 +58,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 6), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -77,13 +84,13 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), anchor: Aligned( target: Alignment.center, - source: Alignment.center, + follower: Alignment.center, ), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -95,11 +102,11 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 6), anchor: Aligned.center, - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -121,9 +128,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -135,10 +142,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -156,9 +163,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -170,10 +177,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 5), - portal: Text('portal2'), + portalFollower: Text('portal2'), child: Text('child'), ), ), @@ -186,10 +193,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 20), - portal: Text('portal3'), + portalFollower: Text('portal3'), child: Text('child'), ), ), @@ -208,9 +215,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -222,10 +229,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -237,9 +244,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -261,9 +268,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -273,10 +280,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -288,9 +295,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -304,10 +311,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, closeDuration: Duration(seconds: 5), - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -329,8 +336,8 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( - portal: Text('portal'), + child: PortalTarget( + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -342,9 +349,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -359,8 +366,8 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( - portal: Text('portal'), + child: PortalTarget( + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -378,9 +385,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( anchor: Aligned.center, - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -392,10 +399,10 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( visible: false, anchor: Aligned.center, - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -410,9 +417,9 @@ Future main() async { await tester.pumpWidget( const Boilerplate( child: Portal( - child: PortalEntry( + child: PortalTarget( anchor: Aligned.center, - portal: Text('portal'), + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -453,7 +460,7 @@ Future main() async { test('PortalEntry requires portal if visible is true ', () { expect( - () => PortalEntry(child: Container()), + () => PortalTarget(child: Container()), throwsAssertionError, ); }); @@ -473,8 +480,8 @@ Future main() async { Boilerplate( child: Portal( child: Center( - child: PortalEntry( - portal: firstPortal, + child: PortalTarget( + portalFollower: firstPortal, child: firstChild, ), ), @@ -498,9 +505,9 @@ Future main() async { "portals aren't inserted if mounted is false, and visible can be changed any time", (tester) async { final portal = ValueNotifier( - PortalEntry( + PortalTarget( visible: false, - portal: Builder(builder: (_) => throw Error()), + portalFollower: Builder(builder: (_) => throw Error()), child: const Text('firstChild'), ), ); @@ -520,8 +527,8 @@ Future main() async { final portalChildElement = tester.element(find.text('firstChild')); - portal.value = const PortalEntry( - portal: Text('secondPortal'), + portal.value = const PortalTarget( + portalFollower: Text('secondPortal'), child: Text('secondChild'), ); await tester.pump(); @@ -536,9 +543,9 @@ Future main() async { reason: 'the child state must be preserved when toggling `visible`', ); - portal.value = PortalEntry( + portal.value = PortalTarget( visible: false, - portal: Builder(builder: (_) => throw Error()), + portalFollower: Builder(builder: (_) => throw Error()), child: const Text('thirdChild'), ); await tester.pump(); @@ -565,8 +572,8 @@ Future main() async { final portal = ValueNotifier( Center( - child: PortalEntry( - portal: portalChild, + child: PortalTarget( + portalFollower: portalChild, child: Center(child: child), ), ), @@ -596,11 +603,11 @@ Future main() async { await expectLater(find.byType(Portal), matchesGoldenFile('unmounted.png')); }); - testWidgets('throws if no PortalEntry were found', (tester) async { + testWidgets('throws if no Portal was found', (tester) async { await tester.pumpWidget( - const PortalEntry( + const PortalTarget( closeDuration: Duration(seconds: 5), - portal: Text('portal', textDirection: TextDirection.ltr), + portalFollower: Text('portal', textDirection: TextDirection.ltr), child: Text('child', textDirection: TextDirection.ltr), ), ); @@ -610,10 +617,10 @@ Future main() async { expect( exception.toString(), equals('Error: Could not find a Portal above this ' - 'PortalEntry(' - "anchor: Instance of 'FullScreen', " + 'PortalTarget(' + "anchor: Instance of 'Filled', " 'closeDuration: 0:00:05.000000, ' - 'portal: Text, child: Text).\n'), + 'portalFollower: Text, child: Text).\n'), ); }); @@ -626,21 +633,21 @@ Future main() async { child: ValueListenableBuilder( valueListenable: notifier, builder: (c, value, _) { - return PortalEntry( + return PortalTarget( visible: value, - portal: Container( + portalFollower: Container( color: Colors.red.withAlpha(122), ), child: Center( - child: PortalEntry( + child: PortalTarget( visible: value, - portal: Container( + portalFollower: Container( height: 50, width: 50, color: Colors.blue, ), anchor: const Aligned( - source: Alignment.bottomCenter, + follower: Alignment.bottomCenter, target: Alignment.topCenter, ), child: Container( @@ -678,8 +685,8 @@ Future main() async { Boilerplate( child: Portal( child: Center( - child: PortalEntry( - portal: portal, + child: PortalTarget( + portalFollower: portal, child: child, ), ), @@ -722,8 +729,8 @@ Future main() async { expect(find.byWidget(first), findsOneWidget); - child.value = PortalEntry( - portal: portal, + child.value = PortalTarget( + portalFollower: portal, child: second, ); await tester.pump(); @@ -745,8 +752,8 @@ Future main() async { await tester.pumpWidget( Boilerplate( child: Portal( - child: PortalEntry( - portal: ElevatedButton( + child: PortalTarget( + portalFollower: ElevatedButton( onPressed: () => portalClickCount++, child: const Text('portal'), ), @@ -775,12 +782,12 @@ Future main() async { child: Portal( // center the entry otherwise the portal is outside the screen child: Center( - child: PortalEntry( + child: PortalTarget( anchor: const Aligned( - source: Alignment.bottomCenter, + follower: Alignment.bottomCenter, target: Alignment.topCenter, ), - portal: ElevatedButton( + portalFollower: ElevatedButton( onPressed: () => portalClickCount++, child: const Text('portal'), ), @@ -814,10 +821,10 @@ Future main() async { child: Portal( child: Align( alignment: Alignment.topLeft, - child: PortalEntry( + child: PortalTarget( anchor: Aligned( - source: Alignment.topLeft, target: Alignment.bottomLeft), - portal: SizedBox(key: portalKey, height: 42, width: 24), + follower: Alignment.topLeft, target: Alignment.bottomLeft), + portalFollower: SizedBox(key: portalKey, height: 42, width: 24), child: SizedBox(key: childKey, height: 10, width: 10), ), ), @@ -846,12 +853,12 @@ Future main() async { child: Portal( child: Align( alignment: Alignment.topRight, - child: PortalEntry( + child: PortalTarget( anchor: Aligned( - source: Alignment.topRight, + follower: Alignment.topRight, target: Alignment.bottomRight, ), - portal: SizedBox(key: portalKey, height: 24, width: 42), + portalFollower: SizedBox(key: portalKey, height: 24, width: 42), child: SizedBox(key: childKey, height: 20, width: 20), ), ), @@ -883,12 +890,12 @@ Future main() async { child: Portal( child: Align( alignment: Alignment.bottomRight, - child: PortalEntry( + child: PortalTarget( anchor: Aligned( - source: Alignment.bottomRight, + follower: Alignment.bottomRight, target: Alignment.topRight, ), - portal: SizedBox(key: portalKey, height: 20, width: 20), + portalFollower: SizedBox(key: portalKey, height: 20, width: 20), child: SizedBox(key: childKey, height: 10, width: 10), ), ), @@ -924,8 +931,8 @@ Future main() async { textDirection: TextDirection.ltr, child: Portal( child: Align( - child: PortalEntry( - portal: SizedBox(key: portalKey, height: 20, width: 20), + child: PortalTarget( + portalFollower: SizedBox(key: portalKey, height: 20, width: 20), child: SizedBox(key: childKey, height: 10, width: 10), ), ), @@ -965,8 +972,8 @@ Future main() async { textDirection: TextDirection.ltr, child: Portal( child: Center( - child: PortalEntry( - portal: GestureDetector( + child: PortalTarget( + portalFollower: GestureDetector( key: portalKey, onTap: () => portalClickCount++, child: child, @@ -1000,12 +1007,12 @@ Future main() async { textDirection: TextDirection.ltr, child: Portal( child: Center( - child: PortalEntry( + child: PortalTarget( anchor: const Aligned( - source: Alignment.bottomCenter, + follower: Alignment.bottomCenter, target: Alignment.topCenter, ), - portal: GestureDetector( + portalFollower: GestureDetector( key: portalKey, onTap: () => portalClickCount++, child: child, @@ -1044,8 +1051,8 @@ Future main() async { textDirection: TextDirection.ltr, child: Portal( child: Center( - child: PortalEntry( - portal: GestureDetector( + child: PortalTarget( + portalFollower: GestureDetector( key: portalKey, onTap: () => portalClickCount++, child: child, @@ -1097,12 +1104,12 @@ Future main() async { Boilerplate( child: Portal( child: Center( - child: PortalEntry( + child: PortalTarget( anchor: const Aligned( - source: Alignment.bottomCenter, + follower: Alignment.bottomCenter, target: Alignment.topCenter, ), - portal: portal, + portalFollower: portal, child: child, ), ), @@ -1134,8 +1141,9 @@ Future main() async { Boilerplate( child: Portal( child: Center( - child: PortalEntry( - portal: Align(alignment: Alignment.topLeft, child: portal), + child: PortalTarget( + portalFollower: + Align(alignment: Alignment.topLeft, child: portal), child: child, ), ), @@ -1162,53 +1170,13 @@ Future main() async { expect(didClickPortal, isTrue); }); - testWidgets('PortalEntry target its generic parameter', (tester) async { - // final portalKey = UniqueKey(); - - // await tester.pumpWidget( - // TestPortal( - // child: Center( - // child: Portal( - // child: PortalEntry( - // // Fills the portal so that if it's added to TestPortal it'll be on the top-left - // // but if it's added to Portal, it'll start in the center of the screen. - // portal: Container(key: portalKey), - // child: const Text('child', textDirection: TextDirection.ltr), - // ), - // ), - // ), - // ), - // ); - - // expect(find.text('child'), findsOneWidget); - // expect( - // tester.getTopLeft(find.byKey(portalKey)), - // equals(Offset.zero), - // ); - }, skip: true); - - testWidgets( - "PortalEntry doesn't fallback to Portal if generic doesn't exists", - (tester) async { - // await tester.pumpWidget( - // Portal( - // child: PortalEntry( - // portal: const Text('portal', textDirection: TextDirection.ltr), - // child: Container(), - // ), - // ), - // ); - - // expect(tester.takeException(), isA()); - }, skip: true); - testWidgets('portals can fill the Portal', (tester) async { final portal = Container(); await tester.pumpWidget( Portal( child: Center( - child: PortalEntry( - portal: portal, + child: PortalTarget( + portalFollower: portal, child: const Text('child', textDirection: TextDirection.ltr), ), ), @@ -1227,8 +1195,8 @@ Future main() async { await tester.pumpWidget( MaterialApp( builder: (_, child) => Portal(child: child!), - home: const PortalEntry( - portal: Text('portal'), + home: const PortalTarget( + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -1243,8 +1211,8 @@ Future main() async { await tester.pumpWidget( CupertinoApp( builder: (_, child) => Portal(child: child!), - home: const PortalEntry( - portal: Text('portal'), + home: const PortalTarget( + portalFollower: Text('portal'), child: Text('child'), ), ), @@ -1269,8 +1237,8 @@ Future main() async { builder: (c, value, _) { return LayoutBuilder( builder: (_, __) { - return PortalEntry( - portal: ValueListenableBuilder( + return PortalTarget( + portalFollower: ValueListenableBuilder( valueListenable: entryNotifier, builder: (_, value2, __) { entryBuild(value, value2); @@ -1310,8 +1278,8 @@ Future main() async { await tester.pumpWidget(Portal( child: LayoutBuilder( builder: (_, __) { - return const PortalEntry( - portal: Text('portal', textDirection: TextDirection.ltr), + return const PortalTarget( + portalFollower: Text('portal', textDirection: TextDirection.ltr), child: Text('child', textDirection: TextDirection.ltr), ); }, @@ -1344,8 +1312,8 @@ Future main() async { notifier.value = LayoutBuilder( builder: (_, __) { - return const PortalEntry( - portal: Text('portal', textDirection: TextDirection.ltr), + return const PortalTarget( + portalFollower: Text('portal', textDirection: TextDirection.ltr), child: Text('child2', textDirection: TextDirection.ltr), ); }, @@ -1360,18 +1328,18 @@ Future main() async { testWidgets('handles reparenting with GlobalKey', (tester) async { // final firstPortal = UniqueKey(); // final secondPortal = UniqueKey(); - + // // final entryKey = GlobalKey(); - + // // await tester.pumpWidget( // Row( // textDirection: TextDirection.ltr, // children: [ // Portal( // key: firstPortal, - // child: PortalEntry( + // child: PortalTarget( // key: entryKey, - // portal: Container(), + // portalFollower: Container(), // child: Container(), // ), // ), @@ -1379,19 +1347,19 @@ Future main() async { // ], // ), // ); - + // // final firstPortalElement = // tester.element(find.byKey(firstPortal)) as PortalElement; // final secondPortalElement = // tester.element(find.byKey(secondPortal)) as PortalElement; - + // // expect(firstPortalElement.theater.entries.length, 1); // expect(firstPortalElement.theater.renderObject.builders.length, 1); // expect(firstPortalElement.theater.renderObject.childCount, 1); // expect(secondPortalElement.theater.entries.length, 0); // expect(secondPortalElement.theater.renderObject.builders.length, 0); // expect(secondPortalElement.theater.renderObject.childCount, 0); - + // // await tester.pumpWidget( // Row( // textDirection: TextDirection.ltr, @@ -1402,32 +1370,32 @@ Future main() async { // ), // Portal( // key: secondPortal, - // child: PortalEntry( + // child: PortalTarget( // key: entryKey, - // portal: Container(), + // portalFollower: Container(), // child: Container(), // ), // ), // ], // ), // ); - + // // expect(firstPortalElement.theater.entries.length, 0); // expect(firstPortalElement.theater.renderObject.builders.length, 0); // expect(firstPortalElement.theater.renderObject.childCount, 0); // expect(secondPortalElement.theater.entries.length, 1); // expect(secondPortalElement.theater.renderObject.builders.length, 1); // expect(secondPortalElement.theater.renderObject.childCount, 1); - + // // await tester.pumpWidget( // Row( // textDirection: TextDirection.ltr, // children: [ // Portal( // key: firstPortal, - // child: PortalEntry( + // child: PortalTarget( // key: entryKey, - // portal: Container(), + // portalFollower: Container(), // child: Container(), // ), // ), @@ -1435,32 +1403,32 @@ Future main() async { // ], // ), // ); - + // // expect(firstPortalElement.theater.entries.length, 1); // expect(firstPortalElement.theater.renderObject.builders.length, 1); // expect(firstPortalElement.theater.renderObject.childCount, 1); // expect(secondPortalElement.theater.entries.length, 0); // expect(secondPortalElement.theater.renderObject.builders.length, 0); // expect(secondPortalElement.theater.renderObject.childCount, 0); - }); + }, skip: true); testWidgets('clip overflow', (tester) async {}, skip: true); testWidgets('can have multiple portals', (tester) async { - final topLeft = PortalEntry( - portal: const Align(alignment: Alignment.topLeft), + final topLeft = PortalTarget( + portalFollower: const Align(alignment: Alignment.topLeft), child: Container(), ); - final topRight = PortalEntry( - portal: const Align(alignment: Alignment.topRight), + final topRight = PortalTarget( + portalFollower: const Align(alignment: Alignment.topRight), child: Container(), ); - final bottomRight = PortalEntry( - portal: const Align(alignment: Alignment.bottomRight), + final bottomRight = PortalTarget( + portalFollower: const Align(alignment: Alignment.bottomRight), child: Container(), ); - final bottomLeft = PortalEntry( - portal: const Align(alignment: Alignment.bottomLeft), + final bottomLeft = PortalTarget( + portalFollower: const Align(alignment: Alignment.bottomLeft), child: Container(), ); @@ -1494,13 +1462,13 @@ Future main() async { var didClickSecond = false; await tester.pumpWidget( Portal( - child: PortalEntry( - portal: GestureDetector( + child: PortalTarget( + portalFollower: GestureDetector( onTap: () => didClickFirst = true, child: const Text('first', textDirection: TextDirection.ltr), ), - child: PortalEntry( - portal: Center( + child: PortalTarget( + portalFollower: Center( child: GestureDetector( onTap: () => didClickSecond = true, child: const Text('second', textDirection: TextDirection.ltr), @@ -1535,13 +1503,13 @@ Future main() async { await tester.pumpWidget( Portal( - child: PortalEntry( - portal: Container( + child: PortalTarget( + portalFollower: Container( margin: const EdgeInsets.all(10), color: Colors.red, ), - child: PortalEntry( - portal: Center( + child: PortalTarget( + portalFollower: Center( child: Container( height: 30, width: 30, @@ -1576,6 +1544,7 @@ class Boilerplate extends StatelessWidget { } mixin Noop {} + class TestPortal = Portal with Noop; @immutable