├── example ├── README.md ├── ios │ ├── Runner │ │ ├── Runner-Bridging-Header.h │ │ ├── Assets.xcassets │ │ │ ├── LaunchImage.imageset │ │ │ │ ├── LaunchImage.png │ │ │ │ ├── LaunchImage@2x.png │ │ │ │ ├── LaunchImage@3x.png │ │ │ │ ├── README.md │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ ├── Icon-App-20x20@1x.png │ │ │ │ ├── Icon-App-20x20@2x.png │ │ │ │ ├── Icon-App-20x20@3x.png │ │ │ │ ├── Icon-App-29x29@1x.png │ │ │ │ ├── Icon-App-29x29@2x.png │ │ │ │ ├── Icon-App-29x29@3x.png │ │ │ │ ├── Icon-App-40x40@1x.png │ │ │ │ ├── Icon-App-40x40@2x.png │ │ │ │ ├── Icon-App-40x40@3x.png │ │ │ │ ├── Icon-App-60x60@2x.png │ │ │ │ ├── Icon-App-60x60@3x.png │ │ │ │ ├── Icon-App-76x76@1x.png │ │ │ │ ├── Icon-App-76x76@2x.png │ │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ │ └── Contents.json │ │ ├── AppDelegate.swift │ │ ├── Base.lproj │ │ │ ├── Main.storyboard │ │ │ └── LaunchScreen.storyboard │ │ └── Info.plist │ ├── Flutter │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── AppFrameworkInfo.plist │ ├── Runner.xcodeproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ ├── RunnerTests │ │ └── RunnerTests.swift │ ├── Podfile.lock │ ├── .gitignore │ └── Podfile ├── lib │ ├── logging.dart │ ├── demo_hover.dart │ ├── basics │ │ └── static_positioning.dart │ ├── demo_orbiting_circles.dart │ ├── demo_interactive_viewer.dart │ ├── demo_page_list_viewport.dart │ ├── main.dart │ ├── kitchen_sink │ │ └── demo_kitchen_sink.dart │ ├── infrastructure │ │ └── ball_sandbox.dart │ └── demo_scrollables.dart ├── web │ ├── favicon.png │ ├── icons │ │ ├── Icon-192.png │ │ ├── Icon-512.png │ │ ├── Icon-maskable-192.png │ │ └── Icon-maskable-512.png │ ├── manifest.json │ └── index.html ├── macos │ ├── Runner │ │ ├── Configs │ │ │ ├── Debug.xcconfig │ │ │ ├── Release.xcconfig │ │ │ ├── Warnings.xcconfig │ │ │ └── AppInfo.xcconfig │ │ ├── Assets.xcassets │ │ │ └── AppIcon.appiconset │ │ │ │ ├── app_icon_16.png │ │ │ │ ├── app_icon_32.png │ │ │ │ ├── app_icon_64.png │ │ │ │ ├── app_icon_1024.png │ │ │ │ ├── app_icon_128.png │ │ │ │ ├── app_icon_256.png │ │ │ │ ├── app_icon_512.png │ │ │ │ └── Contents.json │ │ ├── Release.entitlements │ │ ├── AppDelegate.swift │ │ ├── MainFlutterWindow.swift │ │ ├── DebugProfile.entitlements │ │ └── Info.plist │ ├── .gitignore │ ├── Flutter │ │ ├── Flutter-Debug.xcconfig │ │ ├── Flutter-Release.xcconfig │ │ └── GeneratedPluginRegistrant.swift │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── Runner.xcodeproj │ │ ├── project.xcworkspace │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Podfile.lock │ └── Podfile ├── assets │ └── images │ │ └── example-photo-1.jpeg ├── android │ ├── app │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── drawable │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable-v21 │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── values │ │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values-night │ │ │ │ │ │ └── styles.xml │ │ │ │ ├── kotlin │ │ │ │ │ └── com │ │ │ │ │ │ └── example │ │ │ │ │ │ └── example │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ │ ├── debug │ │ │ │ └── AndroidManifest.xml │ │ │ └── profile │ │ │ │ └── AndroidManifest.xml │ │ └── build.gradle.kts │ ├── gradle.properties │ ├── gradle │ │ └── wrapper │ │ │ └── gradle-wrapper.properties │ ├── .gitignore │ ├── build.gradle.kts │ └── settings.gradle.kts ├── .gitignore ├── .metadata ├── analysis_options.yaml ├── pubspec.yaml └── pubspec.lock ├── docs ├── source │ ├── images │ │ └── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ └── site.webmanifest │ ├── index.md │ └── _includes │ │ └── layouts │ │ └── homepage.jinja ├── .gitignore ├── pubspec.yaml ├── bin │ └── follow_the_leader_docs.dart └── analysis_options.yaml ├── test_goldens ├── goldens │ ├── follower_static-alignment.png │ ├── follower_fade-out_beyond-screen.png │ ├── follower_fade-out_beyond-widget.png │ ├── follower_boundary-constraints_screen.png │ ├── follower_boundary-constraints_widget.png │ ├── follower_boundary-constraints_keyboard.png │ ├── follower_preferred-position-aligner_happy-path.png │ ├── follower_preferred-position-aligner_forced-to-flip.png │ ├── follower_fade-out_does-not-fade-with-partial-overlap.png │ ├── follower_preferred-position-aligner_pushed-on-cross-axis.png │ ├── follower_preferred-position-aligner_not-enough-space-on-either-size.png │ └── tools │ │ ├── ftl_gallery_scene.dart │ │ └── test_leader_and_follower_scaffold.dart ├── flutter_test_config.dart ├── follower_static_orientation_test.dart ├── follower_fade_out_test.dart ├── follower_constraints_test.dart └── preferred_position_aligner_test.dart ├── .run └── main.dart.run.xml ├── .metadata ├── lib ├── follow_the_leader.dart └── src │ ├── build_in_order.dart │ ├── follower_extensions.dart │ ├── logging.dart │ ├── leader_link.dart │ └── aligners.dart ├── pubspec.yaml ├── golden_tester.Dockerfile ├── LICENSE ├── .gitignore ├── .github └── workflows │ ├── generate_docs.yml │ └── pr_validation.yml ├── analysis_options.yaml ├── test ├── hittesting_test.dart └── smoke_test.dart ├── README.md └── CHANGELOG.md /example/README.md: -------------------------------------------------------------------------------- 1 | # Follow the Leader - Example App 2 | -------------------------------------------------------------------------------- /example/ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /example/lib/logging.dart: -------------------------------------------------------------------------------- 1 | import 'package:logging/logging.dart'; 2 | 3 | final appLog = Logger("FollowTheLeaderDemo"); 4 | -------------------------------------------------------------------------------- /example/web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/web/favicon.png -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /example/web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/web/icons/Icon-192.png -------------------------------------------------------------------------------- /example/web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/web/icons/Icon-512.png -------------------------------------------------------------------------------- /example/macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /docs/source/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/favicon.ico -------------------------------------------------------------------------------- /example/ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /example/ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | /website_build 3 | 4 | # https://dart.dev/guides/libraries/private-files 5 | # Created by `dart pub` 6 | .dart_tool/ 7 | -------------------------------------------------------------------------------- /example/web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /example/web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /example/assets/images/example-photo-1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/assets/images/example-photo-1.jpeg -------------------------------------------------------------------------------- /docs/source/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/source/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/source/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /example/macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /test_goldens/goldens/follower_static-alignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_static-alignment.png -------------------------------------------------------------------------------- /docs/source/images/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/source/images/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/docs/source/images/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /example/macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /test_goldens/goldens/follower_fade-out_beyond-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_fade-out_beyond-screen.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_fade-out_beyond-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_fade-out_beyond-widget.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_boundary-constraints_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_boundary-constraints_screen.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_boundary-constraints_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_boundary-constraints_widget.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_boundary-constraints_keyboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_boundary-constraints_keyboard.png -------------------------------------------------------------------------------- /example/android/app/src/main/kotlin/com/example/example/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.example 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity : FlutterActivity() 6 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_preferred-position-aligner_happy-path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_preferred-position-aligner_happy-path.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_preferred-position-aligner_forced-to-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_preferred-position-aligner_forced-to-flip.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_fade-out_does-not-fade-with-partial-overlap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_fade-out_does-not-fade-with-partial-overlap.png -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /test_goldens/goldens/follower_preferred-position-aligner_pushed-on-cross-axis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_preferred-position-aligner_pushed-on-cross-axis.png -------------------------------------------------------------------------------- /example/macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | 9 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 10 | } 11 | -------------------------------------------------------------------------------- /test_goldens/goldens/follower_preferred-position-aligner_not-enough-space-on-either-size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flutter-Bounty-Hunters/follow_the_leader/HEAD/test_goldens/goldens/follower_preferred-position-aligner_not-enough-space-on-either-size.png -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip 6 | -------------------------------------------------------------------------------- /docs/source/images/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /docs/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: follow_the_leader_docs 2 | description: Documentation for Follow the Leader 3 | version: 1.0.0 4 | publish_to: none 5 | 6 | environment: 7 | sdk: ^3.0.0 8 | 9 | dependencies: 10 | static_shock: any 11 | 12 | dev_dependencies: 13 | lints: ^2.0.0 14 | test: ^1.21.0 15 | -------------------------------------------------------------------------------- /example/macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome 3 | description: The homepage for my static website 4 | layout: layouts/homepage.jinja 5 | --- 6 | # Hello, world! 7 | 8 | This title and paragraph were written in Markdown. Then, they were converted into HTML, and inserted 9 | into a Jinja template layout for this page. -------------------------------------------------------------------------------- /.run/main.dart.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | .cxx/ 9 | 10 | # Remember to never publicly share your keystore. 11 | # See https://flutter.dev/to/reference-keystore 12 | key.properties 13 | **/*.keystore 14 | **/*.jks 15 | -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: cf4400006550b70f28e4b4af815151d1e74846c6 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /example/ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /example/macos/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - FlutterMacOS (1.0.0) 3 | 4 | DEPENDENCIES: 5 | - FlutterMacOS (from `Flutter/ephemeral`) 6 | 7 | EXTERNAL SOURCES: 8 | FlutterMacOS: 9 | :path: Flutter/ephemeral 10 | 11 | SPEC CHECKSUMS: 12 | FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 13 | 14 | PODFILE CHECKSUM: 8ab9ee8c53a4731e417ffd089f6de2045cf3fa46 15 | 16 | COCOAPODS: 1.16.2 17 | -------------------------------------------------------------------------------- /example/macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @main 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | return true 8 | } 9 | 10 | override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 11 | return true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test_goldens/flutter_test_config.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 5 | 6 | Future testExecutable(FutureOr Function() testMain) async { 7 | // Adjust the theme that's applied to all golden tests in this suite. 8 | GoldenSceneTheme.push(GoldenSceneTheme.standard.copyWith( 9 | directory: Directory("./goldens"), 10 | )); 11 | 12 | return testMain(); 13 | } 14 | -------------------------------------------------------------------------------- /lib/follow_the_leader.dart: -------------------------------------------------------------------------------- 1 | library follow_the_leader; 2 | 3 | export 'src/aligners.dart'; 4 | export 'src/build_in_order.dart'; 5 | export 'src/follower.dart'; 6 | export 'src/follower_extensions.dart'; 7 | export 'src/leader_link.dart'; 8 | export 'src/leader.dart'; 9 | export 'src/logging.dart'; 10 | 11 | // Re-export `logging` package so users don't need to add it to their pubspec just to 12 | // mess with log levels. 13 | export 'package:logging/logging.dart'; 14 | -------------------------------------------------------------------------------- /example/ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController.init() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example/macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.server 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Flutter (1.0.0) 3 | - super_keyboard (0.0.1): 4 | - Flutter 5 | 6 | DEPENDENCIES: 7 | - Flutter (from `Flutter`) 8 | - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) 9 | 10 | EXTERNAL SOURCES: 11 | Flutter: 12 | :path: Flutter 13 | super_keyboard: 14 | :path: ".symlinks/plugins/super_keyboard/ios" 15 | 16 | SPEC CHECKSUMS: 17 | Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 18 | super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 19 | 20 | PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 21 | 22 | COCOAPODS: 1.16.2 23 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: follow_the_leader 2 | description: Widgets that follow other widgets. 3 | homepage: https://github.com/Flutter-Bounty-Hunters/follow_the_leader 4 | repository: https://github.com/Flutter-Bounty-Hunters/follow_the_leader 5 | 6 | version: 0.5.2 7 | 8 | environment: 9 | sdk: ">=3.0.0 <4.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | logging: ^1.0.1 16 | super_keyboard: ^0.3.0 17 | vector_math: ^2.1.2 18 | 19 | dev_dependencies: 20 | flutter_test: 21 | sdk: flutter 22 | flutter_lints: ^1.0.0 23 | 24 | flutter_test_goldens: 0.0.7 25 | 26 | flutter: 27 | 28 | -------------------------------------------------------------------------------- /example/android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() 9 | rootProject.layout.buildDirectory.value(newBuildDir) 10 | 11 | subprojects { 12 | val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 13 | project.layout.buildDirectory.value(newSubprojectBuildDir) 14 | } 15 | subprojects { 16 | project.evaluationDependsOn(":app") 17 | } 18 | 19 | tasks.register("clean") { 20 | delete(rootProject.layout.buildDirectory) 21 | } 22 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /example/macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = example 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = com.example.example 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. 15 | -------------------------------------------------------------------------------- /example/ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /golden_tester.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | ENV FLUTTER_HOME=${HOME}/sdks/flutter 4 | ENV PATH ${PATH}:${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin 5 | 6 | USER root 7 | 8 | RUN apt update 9 | 10 | RUN apt install -y git curl unzip 11 | 12 | # Print the Ubuntu version. Useful when there are failing tests. 13 | RUN cat /etc/lsb-release 14 | 15 | # Invalidate the cache when flutter pushes a new commit. 16 | ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/stable ./flutter-latest-stable 17 | 18 | RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} 19 | 20 | RUN flutter doctor 21 | 22 | # Copy the whole repo. 23 | # We need this because we use local dependencies. 24 | COPY ./ /golden_tester 25 | -------------------------------------------------------------------------------- /docs/bin/follow_the_leader_docs.dart: -------------------------------------------------------------------------------- 1 | import 'package:static_shock/static_shock.dart'; 2 | 3 | Future main(List arguments) async { 4 | // Configure the static website generator. 5 | final staticShock = StaticShock() 6 | // Here, you can directly hook into the StaticShock pipeline. For example, 7 | // you can copy an "images" directory from the source set to build set: 8 | ..pick(DirectoryPicker.parse("images")) 9 | // All 3rd party behavior is added through plugins, even the behavior 10 | // shipped with Static Shock. 11 | ..plugin(const MarkdownPlugin()) 12 | ..plugin(const JinjaPlugin()) 13 | ..plugin(const PrettyUrlsPlugin()) 14 | ..plugin(const SassPlugin()); 15 | 16 | // Generate the static website. 17 | await staticShock.generateSite(); 18 | } 19 | -------------------------------------------------------------------------------- /example/android/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val flutterSdkPath = run { 3 | val properties = java.util.Properties() 4 | file("local.properties").inputStream().use { properties.load(it) } 5 | val flutterSdkPath = properties.getProperty("flutter.sdk") 6 | require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } 7 | flutterSdkPath 8 | } 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id("dev.flutter.flutter-plugin-loader") version "1.0.0" 21 | id("com.android.application") version "8.7.3" apply false 22 | id("org.jetbrains.kotlin.android") version "2.1.0" apply false 23 | } 24 | 25 | include(":app") 26 | -------------------------------------------------------------------------------- /example/ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins 31 | .flutter-plugins-dependencies 32 | .packages 33 | .pub-cache/ 34 | .pub/ 35 | /build/ 36 | 37 | # Web related 38 | lib/generated_plugin_registrant.dart 39 | 40 | # Symbolication related 41 | app.*.symbols 42 | 43 | # Obfuscation related 44 | app.*.map.json 45 | 46 | # Android Studio will place build artifacts here 47 | /android/app/debug 48 | /android/app/profile 49 | /android/app/release 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Declarative, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Golden tests 2 | test/**/failures/ 3 | test_goldens/**/failures/ 4 | 5 | # Miscellaneous 6 | *.class 7 | *.log 8 | *.pyc 9 | *.swp 10 | .DS_Store 11 | .atom/ 12 | .buildlog/ 13 | .history 14 | .svn/ 15 | 16 | # IntelliJ related 17 | *.iml 18 | *.ipr 19 | *.iws 20 | .idea/ 21 | 22 | # The .vscode folder contains launch configuration and tasks you configure in 23 | # VS Code which you may wish to be included in version control, so this line 24 | # is commented out by default. 25 | #.vscode/ 26 | 27 | # Flutter/Dart/Pub related 28 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 29 | /pubspec.lock 30 | **/doc/api/ 31 | .dart_tool/ 32 | .packages 33 | build/ 34 | .flutter-plugins-dependencies 35 | 36 | # Web related 37 | lib/generated_plugin_registrant.dart 38 | 39 | # Symbolication related 40 | app.*.symbols 41 | 42 | # Obfuscation related 43 | app.*.map.json 44 | 45 | # Android Studio will place build artifacts here 46 | /android/app/debug 47 | /android/app/profile 48 | /android/app/release 49 | -------------------------------------------------------------------------------- /example/web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "short_name": "example", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "8defaa71a77c16e8547abdbfad2053ce3a6e2d5b" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b 17 | base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b 18 | - platform: android 19 | create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b 20 | base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /docs/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /example/lib/demo_hover.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class HoverDemo extends StatefulWidget { 4 | const HoverDemo({Key? key}) : super(key: key); 5 | 6 | @override 7 | State createState() => _HoverDemoState(); 8 | } 9 | 10 | class _HoverDemoState extends State { 11 | @override 12 | Widget build(BuildContext context) { 13 | return Container( 14 | width: double.infinity, 15 | height: double.infinity, 16 | color: const Color(0xFF222222), 17 | child: const Center( 18 | child: HoverPuck( 19 | color: Colors.red, 20 | ), 21 | ), 22 | ); 23 | } 24 | } 25 | 26 | class HoverPuck extends StatelessWidget { 27 | const HoverPuck({ 28 | Key? key, 29 | required this.color, 30 | this.elevation = 15, 31 | }) : super(key: key); 32 | 33 | final Color color; 34 | final double elevation; 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | return Material( 39 | shape: const CircleBorder(), 40 | color: color, 41 | elevation: elevation, 42 | child: const SizedBox( 43 | width: 42, 44 | height: 42, 45 | ), 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/source/_includes/layouts/homepage.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | {{ content }} 23 |
24 | 25 | -------------------------------------------------------------------------------- /test_goldens/goldens/tools/ftl_gallery_scene.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 3 | 4 | const ftlGridGoldenSceneLayout = GridGoldenSceneLayout( 5 | spacing: GridSpacing(around: EdgeInsets.all(48), between: 24), 6 | background: GoldenSceneBackground.color(Color(0xff01040d)), 7 | itemDecorator: _itemDecorator, 8 | ); 9 | 10 | Widget _itemDecorator( 11 | BuildContext context, 12 | GoldenScreenshotMetadata metadata, 13 | Widget content, 14 | ) { 15 | return ColoredBox( 16 | color: const Color(0xff020817), 17 | child: PixelSnapColumn( 18 | mainAxisSize: MainAxisSize.min, 19 | crossAxisAlignment: CrossAxisAlignment.center, 20 | children: [ 21 | PixelSnapCenter( 22 | child: content, 23 | ), 24 | Padding( 25 | padding: const EdgeInsets.all(24), 26 | child: Text( 27 | metadata.description, 28 | textAlign: TextAlign.center, 29 | style: const TextStyle( 30 | color: Color(0xff1e293b), 31 | fontFamily: TestFonts.openSans, 32 | fontSize: 22, 33 | fontWeight: FontWeight.bold, 34 | ), 35 | ), 36 | ), 37 | ], 38 | ), 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/generate_docs.yml: -------------------------------------------------------------------------------- 1 | name: Generate documentation website 2 | on: 3 | push: 4 | branches: 5 | main 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: ./docs 12 | steps: 13 | # Checkout the repository 14 | - uses: actions/checkout@v3 15 | 16 | # Setup a Dart environment 17 | - uses: dart-lang/setup-dart@v1 18 | 19 | # Download all the packages that the app uses 20 | - run: dart pub get 21 | 22 | # Build the static site 23 | - run: dart run bin/follow_the_leader_docs.dart 24 | 25 | # Zip and upload the static site. 26 | - name: Upload artifact 27 | uses: actions/upload-pages-artifact@v1 28 | with: 29 | path: ./docs/build 30 | 31 | deploy: 32 | name: Deploy 33 | needs: build 34 | runs-on: ubuntu-latest 35 | 36 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 37 | permissions: 38 | pages: write # to deploy to Pages 39 | id-token: write # to verify the deployment originates from an appropriate source 40 | 41 | environment: 42 | name: github-pages 43 | url: ${{ steps.deployment.outputs.page_url }} 44 | 45 | steps: 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v2 49 | -------------------------------------------------------------------------------- /example/macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '10.14' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | 32 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 33 | # target 'RunnerTests' do 34 | # inherit! :search_paths 35 | # end 36 | end 37 | 38 | post_install do |installer| 39 | installer.pods_project.targets.each do |target| 40 | flutter_additional_macos_build_settings(target) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /example/ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | 33 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 34 | target 'RunnerTests' do 35 | inherit! :search_paths 36 | end 37 | end 38 | 39 | post_install do |installer| 40 | installer.pods_project.targets.each do |target| 41 | flutter_additional_ios_build_settings(target) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /example/android/app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 | id("dev.flutter.flutter-gradle-plugin") 6 | } 7 | 8 | android { 9 | namespace = "com.example.example" 10 | compileSdk = flutter.compileSdkVersion 11 | ndkVersion = flutter.ndkVersion 12 | 13 | compileOptions { 14 | sourceCompatibility = JavaVersion.VERSION_11 15 | targetCompatibility = JavaVersion.VERSION_11 16 | } 17 | 18 | kotlinOptions { 19 | jvmTarget = JavaVersion.VERSION_11.toString() 20 | } 21 | 22 | defaultConfig { 23 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 24 | applicationId = "com.example.example" 25 | // You can update the following values to match your application needs. 26 | // For more information, see: https://flutter.dev/to/review-gradle-config. 27 | minSdk = flutter.minSdkVersion 28 | targetSdk = flutter.targetSdkVersion 29 | versionCode = flutter.versionCode 30 | versionName = flutter.versionName 31 | } 32 | 33 | buildTypes { 34 | release { 35 | // TODO: Add your own signing config for the release build. 36 | // Signing with the debug keys for now, so `flutter run --release` works. 37 | signingConfig = signingConfigs.getByName("debug") 38 | } 39 | } 40 | } 41 | 42 | flutter { 43 | source = "../.." 44 | } 45 | -------------------------------------------------------------------------------- /example/lib/basics/static_positioning.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | 4 | class StaticPositioningDemo extends StatelessWidget { 5 | const StaticPositioningDemo({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return _buildLeaderAndFollower(const Offset(0, 20)); 10 | } 11 | } 12 | 13 | Widget _buildLeaderAndFollower(Offset followerOffset) { 14 | final link = LeaderLink(); 15 | return ColoredBox( 16 | color: Colors.grey, 17 | child: Stack( 18 | children: [ 19 | Center( 20 | child: Leader( 21 | link: link, 22 | child: const _TestLeader(), 23 | ), 24 | ), 25 | Follower.withOffset( 26 | link: link, 27 | leaderAnchor: Alignment.bottomCenter, 28 | followerAnchor: Alignment.topCenter, 29 | offset: followerOffset, 30 | child: const _TestFollower(), 31 | ), 32 | ], 33 | ), 34 | ); 35 | } 36 | 37 | class _TestLeader extends StatelessWidget { 38 | const _TestLeader(); 39 | 40 | @override 41 | Widget build(BuildContext context) { 42 | return Container( 43 | width: 10, 44 | height: 10, 45 | color: Colors.blue, 46 | ); 47 | } 48 | } 49 | 50 | class _TestFollower extends StatelessWidget { 51 | const _TestFollower(); 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | return Container( 56 | width: 20, 57 | height: 20, 58 | color: Colors.red, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/pr_validation.yml: -------------------------------------------------------------------------------- 1 | name: Run analysis and tests for pull requests 2 | on: [pull_request] 3 | jobs: 4 | analyze: 5 | runs-on: ubuntu-latest 6 | steps: 7 | # Checkout the PR branch 8 | - uses: actions/checkout@v3 9 | 10 | # Setup Flutter environment 11 | - uses: subosito/flutter-action@v2 12 | with: 13 | channel: "stable" 14 | 15 | # Download all the packages that the app uses 16 | - run: flutter pub get 17 | 18 | # Static analysis 19 | - run: flutter analyze 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | # Checkout the PR branch 25 | - uses: actions/checkout@v3 26 | 27 | # Setup Flutter environment 28 | - uses: subosito/flutter-action@v2 29 | with: 30 | channel: "stable" 31 | 32 | # Download all the packages that the app uses 33 | - run: flutter pub get 34 | 35 | # Run all tests 36 | - run: flutter test 37 | 38 | test_goldens: 39 | runs-on: ubuntu-latest 40 | steps: 41 | # Checkout the PR branch 42 | - uses: actions/checkout@v3 43 | 44 | # Setup Flutter environment 45 | - uses: subosito/flutter-action@v2 46 | with: 47 | channel: "stable" 48 | 49 | # Download all the packages that the app uses 50 | - run: flutter pub get 51 | 52 | # Run all tests 53 | - run: flutter test test_goldens 54 | 55 | # Archive golden failures 56 | - uses: actions/upload-artifact@v4 57 | if: failure() 58 | with: 59 | name: golden-failures 60 | path: "**/failures/**/*.png" 61 | -------------------------------------------------------------------------------- /example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "app_icon_16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "app_icon_32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "app_icon_32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "app_icon_64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "app_icon_128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "app_icon_256.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "app_icon_256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "app_icon_512.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "app_icon_512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "app_icon_1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | analyzer: 13 | exclude: 14 | - docs/** 15 | 16 | linter: 17 | # The lint rules applied to this project can be customized in the 18 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 19 | # included above or to enable additional rules. A list of all available lints 20 | # and their documentation is published at 21 | # https://dart-lang.github.io/linter/lints/index.html. 22 | # 23 | # Instead of disabling a lint rule for the entire project in the 24 | # section below, it can also be suppressed for a single line of code 25 | # or a specific dart file by using the `// ignore: name_of_lint` and 26 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 27 | # producing the lint. 28 | rules: 29 | always_use_package_imports: true 30 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 31 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 32 | 33 | # Additional information about this file can be found at 34 | # https://dart.dev/guides/language/analysis-options 35 | -------------------------------------------------------------------------------- /example/ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Example 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | example 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | CADisableMinimumFrameDurationOnPhone 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /example/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | example 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /lib/src/build_in_order.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/rendering.dart'; 3 | 4 | /// A [Widget] that builds its [children] in the order that they're provided, 5 | /// using the constraints from this widget's parent. 6 | /// 7 | /// Consider using [BuildInOrder] when you're building widgets that pull themselves 8 | /// out of the standard layout protocol, such as `Follower` widgets. This widget 9 | /// doesn't provide any special behavior for `Follower` widgets, or any others, but 10 | /// this widget tells the reader that there's no intended layout behavior. Instead, 11 | /// the only detail that matters is the build order of the children. 12 | class BuildInOrder extends MultiChildRenderObjectWidget { 13 | const BuildInOrder({ 14 | Key? key, 15 | required List children, 16 | }) : super(key: key, children: children); 17 | 18 | @override 19 | RenderObject createRenderObject(BuildContext context) { 20 | return RenderBuildInOrder(); 21 | } 22 | } 23 | 24 | /// [RenderBox] for a [BuildInOrder] widget. 25 | class RenderBuildInOrder extends RenderBox 26 | with ContainerRenderObjectMixin>, RenderBoxContainerDefaultsMixin { 27 | @override 28 | ContainerBoxParentData setupParentData(RenderBox child) { 29 | child.parentData = MultiChildLayoutParentData(); 30 | return child.parentData as MultiChildLayoutParentData; 31 | } 32 | 33 | @override 34 | void performLayout() { 35 | size = constraints.biggest; 36 | 37 | final childConstraints = constraints.loosen(); 38 | RenderBox? child = firstChild; 39 | while (child != null) { 40 | child.layout(childConstraints); 41 | child = childAfter(child); 42 | } 43 | } 44 | 45 | @override 46 | void paint(PaintingContext context, Offset? offset) { 47 | defaultPaint(context, offset ?? Offset.zero); 48 | } 49 | 50 | @override 51 | bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { 52 | return defaultHitTestChildren(result, position: position); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /example/ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /test/hittesting_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:follow_the_leader/src/follower.dart'; 4 | import 'package:follow_the_leader/src/leader_link.dart'; 5 | import 'package:follow_the_leader/src/leader.dart'; 6 | 7 | void main() { 8 | group('follow the leader', () { 9 | testWidgets('hit tests followers', (tester) async { 10 | tester.view 11 | ..physicalSize = const Size(400, 400) 12 | ..devicePixelRatio = 1.0; 13 | 14 | bool tapped = false; 15 | final _link = LeaderLink(); 16 | 17 | await tester.pumpWidget( 18 | MaterialApp( 19 | home: Scaffold( 20 | body: Builder(builder: (context) { 21 | return Stack( 22 | children: [ 23 | Positioned( 24 | top: 0, 25 | left: 0, 26 | child: Leader( 27 | link: _link, 28 | child: Container(color: Colors.red, width: 50, height: 50), 29 | ), 30 | ), 31 | Positioned( 32 | top: 0, 33 | left: 0, 34 | child: Follower.withOffset( 35 | link: _link, 36 | boundary: const ScreenFollowerBoundary(), 37 | leaderAnchor: Alignment.bottomRight, 38 | followerAnchor: Alignment.topLeft, 39 | offset: const Offset(50, 50), 40 | child: GestureDetector( 41 | onTap: () { 42 | tapped = true; 43 | }, 44 | child: Container( 45 | color: Colors.blue, 46 | width: 50, 47 | height: 50, 48 | ), 49 | ), 50 | ), 51 | ), 52 | ], 53 | ); 54 | }), 55 | ), 56 | ), 57 | ); 58 | await tester.pumpAndSettle(); 59 | 60 | // Tap at the follower position 61 | // Leader width + offset + half of the follower width. 62 | await tester.tapAt(const Offset(125, 125)); 63 | await tester.pump(); 64 | 65 | // This golden matcher is available for easy verification if this test fails. 66 | // await expectLater(find.byType(MaterialApp), matchesGoldenFile("deleteme.png")); 67 | 68 | // Ensure the callback was called. 69 | expect(tapped, isTrue); 70 | }); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /test_goldens/follower_static_orientation_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 4 | 5 | import 'goldens/tools/ftl_gallery_scene.dart'; 6 | import 'goldens/tools/test_leader_and_follower_scaffold.dart'; 7 | 8 | void main() { 9 | group("Follower > static orientation >", () { 10 | testGoldenScene("with plenty of space", (tester) async { 11 | await Gallery( 12 | "Static Alignment", 13 | fileName: "follower_static-alignment", 14 | layout: ftlGridGoldenSceneLayout, 15 | itemConstraints: const BoxConstraints.tightFor(width: 150, height: 150), 16 | itemSetup: (tester) async => tester.pump(), 17 | ) 18 | .itemFromWidget( 19 | description: "Top/Left", 20 | widget: buildLeaderAndFollowerWithOffset(followerAlignTopLeft), 21 | ) 22 | .itemFromWidget( 23 | description: "Top", 24 | // Partial pixel alignment problem. 25 | tolerancePx: 80, 26 | widget: buildLeaderAndFollowerWithOffset(followerAlignTop), 27 | ) 28 | .itemFromWidget( 29 | description: "Top/Right", 30 | // Partial pixel alignment problem. 31 | tolerancePx: 80, 32 | widget: buildLeaderAndFollowerWithOffset(followerAlignTopRight), 33 | ) 34 | .itemFromWidget( 35 | description: "Left", 36 | widget: buildLeaderAndFollowerWithOffset(followerAlignLeft), 37 | ) 38 | .itemFromWidget( 39 | description: "Center", 40 | // Partial pixel alignment problem. 41 | tolerancePx: 50, 42 | widget: buildLeaderAndFollowerWithOffset(followerAlignCenter), 43 | ) 44 | .itemFromWidget( 45 | description: "Right", 46 | // Partial pixel alignment problem. 47 | tolerancePx: 80, 48 | widget: buildLeaderAndFollowerWithOffset(followerAlignRight), 49 | ) 50 | .itemFromWidget( 51 | description: "Bottom/Left", 52 | widget: buildLeaderAndFollowerWithOffset(followerAlignBottomLeft), 53 | ) 54 | .itemFromWidget( 55 | description: "Bottom", 56 | // Partial pixel alignment problem. 57 | tolerancePx: 80, 58 | widget: buildLeaderAndFollowerWithOffset(followerAlignBottom), 59 | ) 60 | .itemFromWidget( 61 | description: "Bottom/Right", 62 | // Partial pixel alignment problem. 63 | tolerancePx: 80, 64 | widget: buildLeaderAndFollowerWithOffset(followerAlignBottomRight), 65 | ) 66 | .run(tester); 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: A new Flutter project. 3 | 4 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 5 | 6 | version: 1.0.0+1 7 | 8 | environment: 9 | sdk: ">=2.17.6 <3.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | follow_the_leader: 16 | git: 17 | url: https://github.com/Flutter-Bounty-Hunters/follow_the_leader 18 | 19 | logging: ^1.3.0 20 | overlord: ^0.0.3+2 21 | 22 | page_list_viewport: 23 | git: 24 | url: https://github.com/Flutter-Bounty-Hunters/page_list_viewport 25 | 26 | dependency_overrides: 27 | follow_the_leader: 28 | path: ../ 29 | 30 | overlord: 31 | git: 32 | url: https://github.com/Flutter-Bounty-Hunters/overlord 33 | ref: ebf5d372beba278122525fb957902dec9a80b306 34 | 35 | dev_dependencies: 36 | flutter_test: 37 | sdk: flutter 38 | 39 | # The "flutter_lints" package below contains a set of recommended lints to 40 | # encourage good coding practices. The lint set provided by the package is 41 | # activated in the `analysis_options.yaml` file located at the root of your 42 | # package. See that file for information about deactivating specific lint 43 | # rules and activating additional ones. 44 | flutter_lints: ^2.0.0 45 | golden_toolkit: ^0.11.0 46 | 47 | # For information on the generic Dart part of this file, see the 48 | # following page: https://dart.dev/tools/pub/pubspec 49 | 50 | # The following section is specific to Flutter packages. 51 | flutter: 52 | 53 | # The following line ensures that the Material Icons font is 54 | # included with your application, so that you can use the icons in 55 | # the material Icons class. 56 | uses-material-design: true 57 | 58 | # To add assets to your application, add an assets section, like this: 59 | assets: 60 | - assets/images/ 61 | 62 | # An image asset can refer to one or more resolution-specific "variants", see 63 | # https://flutter.dev/assets-and-images/#resolution-aware 64 | 65 | # For details regarding adding assets from package dependencies, see 66 | # https://flutter.dev/assets-and-images/#from-packages 67 | 68 | # To add custom fonts to your application, add a fonts section here, 69 | # in this "flutter" section. Each entry in this list should have a 70 | # "family" key with the font family name, and a "fonts" key with a 71 | # list giving the asset and other descriptors for the font. For 72 | # example: 73 | # fonts: 74 | # - family: Schyler 75 | # fonts: 76 | # - asset: fonts/Schyler-Regular.ttf 77 | # - asset: fonts/Schyler-Italic.ttf 78 | # style: italic 79 | # - family: Trajan Pro 80 | # fonts: 81 | # - asset: fonts/TrajanPro.ttf 82 | # - asset: fonts/TrajanPro_Bold.ttf 83 | # weight: 700 84 | # 85 | # For details regarding fonts from package dependencies, 86 | # see https://flutter.dev/custom-fonts/#from-packages 87 | -------------------------------------------------------------------------------- /lib/src/follower_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | 4 | /// A `Widget` that fades out when the [Leader] attached to the given [link] 5 | /// exceeds the given [boundary]. 6 | /// 7 | /// For example, if the [boundary] represents the screen size, then when the 8 | /// associated [Leader] widget moves outside the screen boundary, this widget 9 | /// fades out. When the [Leader] re-enters the visible screen area, this 10 | /// widget fades in. 11 | class FollowerFadeOutBeyondBoundary extends StatelessWidget { 12 | const FollowerFadeOutBeyondBoundary({ 13 | Key? key, 14 | required this.link, 15 | this.enabled = true, 16 | this.boundary, 17 | this.duration = const Duration(milliseconds: 250), 18 | this.curve = Curves.linear, 19 | required this.child, 20 | }) : super(key: key); 21 | 22 | /// A [LeaderLink] that's attached to a [Leader] widget, whose offset 23 | /// determines whether this widget should be visible. 24 | final LeaderLink link; 25 | 26 | /// Whether the content should fade-out when the [Leader] is beyond the [boundary]. 27 | final bool enabled; 28 | 29 | /// A [FollowerBoundary], which is combined with the [link] [Leader]'s 30 | /// offset, to determine whether this widget should be visible. 31 | final FollowerBoundary? boundary; 32 | 33 | /// [Duration] to fade out and fade in. 34 | final Duration duration; 35 | 36 | /// The animation [Curve] applied to the fade out and fade in animations. 37 | final Curve curve; 38 | 39 | /// A [Widget] that's following a [Leader] attached to the [link]. 40 | final Widget child; 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | return AnimatedBuilder( 45 | animation: link, 46 | builder: (context, value) { 47 | if (!_isConnectedToLeader || !_hasBoundary) { 48 | // Either we have no leader, or we have no boundary. If we have no 49 | // leader, we're not sure what to do. Let the follower figure it out. 50 | // If we have no boundary, then we should never fade. 51 | return child; 52 | } 53 | 54 | return AnimatedOpacity( 55 | opacity: _isContentVisible(context) || !enabled ? 1.0 : 0.0, 56 | duration: duration, 57 | curve: curve, 58 | child: child, 59 | ); 60 | }, 61 | ); 62 | } 63 | 64 | bool get _isConnectedToLeader => link.offset != null && link.leaderSize != null; 65 | 66 | bool get _hasBoundary => boundary != null; 67 | 68 | bool _isContentVisible(BuildContext context) { 69 | assert(_isConnectedToLeader && _hasBoundary); 70 | 71 | final leaderRect = link.offset! & (link.leaderSize! * (link.scale ?? 1.0)); 72 | final boundsRect = boundary!.calculateGlobalBounds(context); 73 | 74 | // Returns `true` if there's even a partial overlap. 75 | return leaderRect.overlaps(boundsRect); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/logging.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | 3 | import 'package:logging/logging.dart'; 4 | 5 | /// Follow the Leader logger names. 6 | abstract class FtlLogNames { 7 | static const leader = 'ftl.leader'; 8 | static const follower = 'ftl.follower'; 9 | static const link = 'ftl.link'; 10 | static const boundary = 'ftl.boundary'; 11 | static const widgetBoundary = 'ftl.boundary.widget'; 12 | static const _root = 'ftl'; 13 | } 14 | 15 | /// Follow the Leader logging. 16 | abstract class FtlLogs { 17 | static final leader = Logger(FtlLogNames.leader); 18 | static final follower = Logger(FtlLogNames.follower); 19 | static final link = Logger(FtlLogNames.link); 20 | static final boundary = Logger(FtlLogNames.boundary); 21 | static final widgetBoundary = Logger(FtlLogNames.widgetBoundary); 22 | static final _root = Logger(FtlLogNames._root); 23 | 24 | static final _activeLoggers = {}; 25 | 26 | /// Initialize the given [loggers] using the minimum [level]. 27 | /// 28 | /// To enable all the loggers, use [FtlLogs.initAllLogs]. 29 | static void initLoggers(Set loggers, [Level level = Level.ALL]) { 30 | hierarchicalLoggingEnabled = true; 31 | 32 | for (final logger in loggers) { 33 | if (!_activeLoggers.contains(logger)) { 34 | print('Initializing logger: ${logger.name}'); 35 | logger 36 | ..level = level 37 | ..onRecord.listen(_printLog); 38 | 39 | _activeLoggers.add(logger); 40 | } 41 | } 42 | } 43 | 44 | /// Initializes all the available loggers. 45 | /// 46 | /// To control which loggers are initialized, use [FtlLogs.initLoggers]. 47 | static void initAllLogs([Level level = Level.ALL]) { 48 | initLoggers({_root}, level); 49 | } 50 | 51 | /// Returns `true` if the given [logger] is currently logging, or 52 | /// `false` otherwise. 53 | /// 54 | /// Generally, developers should call loggers, regardless of whether 55 | /// a given logger is active. However, sometimes you may want to log 56 | /// information that's costly to compute. In such a case, you can 57 | /// choose to compute the expensive information only if the given 58 | /// logger will actually log the information. 59 | static bool isLogActive(Logger logger) { 60 | return _activeLoggers.contains(logger); 61 | } 62 | 63 | /// Deactivates the given [loggers]. 64 | static void deactivateLoggers(Set loggers) { 65 | for (final logger in loggers) { 66 | if (_activeLoggers.contains(logger)) { 67 | print('Deactivating logger: ${logger.name}'); 68 | logger.clearListeners(); 69 | 70 | _activeLoggers.remove(logger); 71 | } 72 | } 73 | } 74 | 75 | /// Logs a record using a print statement. 76 | static void _printLog(LogRecord record) { 77 | print( 78 | '(${record.time.second}.${record.time.millisecond.toString().padLeft(3, '0')}) ${record.loggerName} > ${record.level.name}: ${record.message}'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /example/lib/demo_orbiting_circles.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:follow_the_leader/follow_the_leader.dart'; 5 | 6 | import 'logging.dart'; 7 | 8 | class OrbitingCirclesDemo extends StatefulWidget { 9 | const OrbitingCirclesDemo({Key? key}) : super(key: key); 10 | 11 | @override 12 | State createState() => _OrbitingCirclesDemoState(); 13 | } 14 | 15 | class _OrbitingCirclesDemoState extends State { 16 | final _screenBoundKey = GlobalKey(); 17 | late LeaderLink _link; 18 | Offset _offset = const Offset(250, 250); 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | _link = LeaderLink(); 24 | } 25 | 26 | void _onPanUpdate(DragUpdateDetails details) { 27 | setState(() { 28 | _offset += details.delta; 29 | }); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | appLog.fine("Rebuilding entire demo"); 35 | return SizedBox( 36 | key: _screenBoundKey, 37 | child: Stack( 38 | children: [ 39 | Positioned( 40 | left: _offset.dx, 41 | top: _offset.dy, 42 | child: FractionalTranslation( 43 | translation: const Offset(-0.5, -0.5), 44 | child: GestureDetector( 45 | onPanUpdate: _onPanUpdate, 46 | child: Leader( 47 | link: _link, 48 | child: Container( 49 | width: 50, 50 | height: 50, 51 | decoration: const BoxDecoration( 52 | shape: BoxShape.circle, 53 | color: Colors.black, 54 | ), 55 | ), 56 | ), 57 | ), 58 | ), 59 | ), 60 | ..._buildFollowers(), 61 | ], 62 | ), 63 | ); 64 | } 65 | 66 | List _buildFollowers() { 67 | const followerCount = 8; 68 | return [ 69 | for (int i = 0; i < followerCount; i += 1) // 70 | _buildFollowerAtAngle((i / followerCount) * (2 * pi)), 71 | ]; 72 | } 73 | 74 | Widget _buildFollowerAtAngle(double radians) { 75 | const radius = 100; 76 | 77 | return Positioned( 78 | left: 0, 79 | top: 0, 80 | child: Follower.withOffset( 81 | link: _link, 82 | offset: Offset( 83 | radius * cos(radians), 84 | radius * sin(radians), 85 | ), 86 | boundary: const ScreenFollowerBoundary(), 87 | leaderAnchor: Alignment.center, 88 | followerAnchor: Alignment.center, 89 | child: Container( 90 | width: 25, 91 | height: 25, 92 | decoration: const BoxDecoration( 93 | shape: BoxShape.circle, 94 | color: Colors.grey, 95 | ), 96 | ), 97 | ), 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Follow the Leader - Widgets following widgets 3 |

4 | 5 |

6 | 7 | Built by the Flutter Bounty Hunters 8 | 9 |

10 | 11 | --- 12 | 13 | ## Getting Started 14 | Select a widget that you want to follow and wrap it with a `Leader` widget. Give the `Leader` 15 | widget a `LeaderLink`, to share with `Follower`s. 16 | 17 | ```dart 18 | Leader( 19 | link: _leaderLink, 20 | child: YourLeaderWidget(), 21 | ); 22 | ``` 23 | 24 | Add a widget that you want to follow your `Leader`, and wrap it with a `Follower` widget. 25 | 26 | ```dart 27 | // Follower appears 20px above the Leader. 28 | Follower.withOffset( 29 | link: _leaderLink, 30 | offset: const Offset(0, -20), 31 | leaderAnchor: Alignment.topCenter, 32 | followerAnchor: Alignment.bottomCenter, 33 | child: YourFollowerWidget(), 34 | ); 35 | ``` 36 | 37 | `Follower`'s can position themselves with a constant distance from a `Leader` using `.withOffset()`, 38 | as shown above. Or, `Follower`'s can choose their exact location on every frame by using 39 | `.withAligner()`. 40 | 41 | ```dart 42 | // Follower appears where the aligner says it should. 43 | Follower.withAligner( 44 | link: _leaderLink, 45 | aligner: _aligner, 46 | child: YourFollowerWidget(), 47 | ); 48 | ``` 49 | 50 | To constrain where your `Follower` is allowed to appear, pass a `boundary` to your `Follower`. 51 | 52 | ```dart 53 | // Follower is constrained by the given boundary. 54 | Follower.withAligner( 55 | link: _leaderLink, 56 | aligner: _aligner, 57 | boundary: _boundary, 58 | child: YourFollowerWidget(), 59 | ); 60 | ``` 61 | 62 | ## Building multiple widgets without layouts 63 | Building follower widgets is a bit unusual with Flutter. Typically, whenever we build multiple 64 | widgets in Flutter, we place them in a layout container, such as `Column`, `Row`, or `Stack`. 65 | But follower widgets don't respect ancestor layout rules. That's the whole point. 66 | 67 | `follow_the_leader` introduces a new container widget, which builds children, but doesn't attempt 68 | to apply any particular layout rules. The primary purpose of this widget is to make it clear to 69 | readers that you aren't trying to layout the children. 70 | 71 | ```dart 72 | BuildInOrder( 73 | children: [ 74 | MyContentWithALeader(), 75 | Follower.withOffset(), 76 | Follower.withDynamics(), 77 | ], 78 | ); 79 | ``` 80 | 81 | The `BuildInOrder` widget builds each child widget in the order that it's provided. This fact is 82 | important because `Leader` widgets must be built before their `Follower`s. But `BuildInOrder` does 83 | not impose any `Offset` on its children. `BuildInOrder` passes its parent's constraints down to the 84 | `children`. 85 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.5.2 2 | ### Nov 4, 2025 3 | * Upgrade `super_keyboard` to `v0.3.0`. 4 | 5 | # 0.5.1 6 | ### Oct 7, 2025 7 | * Follower gap is now measured in DIP instead of PX. 8 | 9 | # 0.5.0 10 | ### July 10, 2025 11 | This release is the same as `0.0.5` - we're pushing up the version number to 12 | opt-in to `0.` semver Pub upgrade rules. This will hopefully avoid unexpected 13 | dependency upgrades for our users in the future. 14 | 15 | # 0.0.5 16 | ### July 9, 2025 17 | FEATURE: Created `KeyboardFollowerBoundary` based on the height reported by `super_keyboard`. 18 | FEATURE: Created `SafeAreaFollowerBoundary` based on the padding reported by `MediaQuery`. 19 | BREAKING: `FollowerBoundary` and `FollowerAligner` contracts have changed: 20 | * `FollowerBoundary` no longer does any positioning - it just returns a global rectangle. 21 | * `FollowerBoundary` now receives a `BuildContext` to help choose the boundary. 22 | * `FollowerAligner`s are now given the desired `globalBounds` and they make the decision 23 | about where to align the `Follower` given those bounds. 24 | * If the aligner still tries to position the `Follower` outside the `globalBounds`, then 25 | the `Follower` internally constrains itself to the nearest bounded offset. 26 | 27 | # 0.0.4+8 28 | ### May, 2024 29 | Hacked around a bug in tests related to render objects still being dirty after the test finishes. 30 | 31 | # 0.0.4+7 32 | ### Nov, 2023 33 | Added a `FunctionalAligner` for easier aligner implementations. 34 | 35 | # 0.0.4+6 36 | ### Oct, 2023 37 | Fix dirty paint state for `Follower`s in Linux golden tests. 38 | 39 | # 0.0.4+5 40 | ### Sept, 2023 41 | More fixes for `Follower` content alignment, e.g., iOS popovers. This fix schedules an extra paint frame if it tries to paint a `Follower` when the `FollowerLayer` isn't attached. 42 | 43 | # 0.0.4+4 44 | ### Sept, 2023 45 | Adjusted `Follower` internal transform management to solve iOS toolbar arrow alignment issues on 46 | first frame, and when focal point moves. 47 | 48 | # 0.0.4+3 49 | ### July, 2023 50 | Fixes and adjustments. 51 | 52 | * FIX: Follower no longer drifts when it hits a boundary 53 | * CHANGE: Follower doesn't fade until the entire Leader leaves the boundary 54 | * Supports Dart 3 55 | 56 | # 0.0.4+2 57 | ### Jan, 2023 58 | `Leader` and `Follower` scaling. 59 | 60 | * Reworked `Follower` implementation to correctly handle scaling. Added bounds support for scaled `Leader`s and `Follower`s. 61 | 62 | # 0.0.4+1 63 | ### Jan, 2023 64 | Fix Leader offset reporting. 65 | 66 | * Fix `getOffsetInLeader` from last release by correctly applying `Leader` scale. 67 | 68 | # 0.0.4 69 | ### Jan, 2023 70 | Scrollables and scaling. 71 | 72 | * Added `recalculateGlobalOffset` to `Leader`, which should be used to notify `Leader`s when an ancestor `Scrollable` scrolls, so the `Leader` can notify `Follower`s that it moved. 73 | * Added `scale` and `getOffsetInLeader` to `LeaderLink` because the `Leader`'s scale was previously ignored. 74 | 75 | # 0.0.3 76 | ### Dec, 2023 77 | Easier following. 78 | 79 | * Breaking: `Follower.withDynamics` is now `Follower.withAligner`. 80 | * `LeaderLink` now mixes `ChangeNotifier` and notifies listeners when the `Leader` moves or changes size. 81 | * Added `Follower` property called `repaintWhenLeaderChanges`, which repaints the `Follower` child whenever the `Leader` moves or changes size. 82 | * Added `FollowerFadeOutBeyondBoundary` widget, which will fade out its child when the `Leader` exceeds a given `FollowerBoundary`. 83 | 84 | # 0.0.2 85 | ### Dec, 2022 86 | MVP release. 87 | 88 | * Primary widgets are now called `Leader` and `Follower` 89 | * The widget link in this package is now called `LeaderLink` 90 | * `Follower` supports customized alignment and boundaries 91 | * `BuildInOrder` lets you build `Follower`s without an implied layout 92 | 93 | # 0.0.1 94 | ### Aug, 2022 95 | Initial release. 96 | 97 | * Not ready for any production use, yet. -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 53 | 54 | 66 | 68 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /example/lib/demo_interactive_viewer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | import 'package:overlord/follow_the_leader.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | class InteractiveViewerDemo extends StatefulWidget { 7 | const InteractiveViewerDemo({Key? key}) : super(key: key); 8 | 9 | @override 10 | State createState() => _InteractiveViewerDemoState(); 11 | } 12 | 13 | class _InteractiveViewerDemoState extends State with SingleTickerProviderStateMixin { 14 | final _leaderLink = LeaderLink(); 15 | late final TransformationController _controller; 16 | 17 | late final _aligner = CupertinoPopoverToolbarAligner(); 18 | late final _focalPoint = LeaderMenuFocalPoint(link: _leaderLink); 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | _controller = TransformationController(); 24 | } 25 | 26 | @override 27 | void dispose() { 28 | _controller.dispose(); 29 | super.dispose(); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return Scaffold( 35 | body: BuildInOrder( 36 | children: [ 37 | InteractiveViewer( 38 | minScale: 0.1, 39 | maxScale: 10.0, 40 | constrained: false, 41 | child: SizedBox( 42 | width: 1920, 43 | height: 2400, 44 | child: LayoutBuilder( 45 | builder: (context, constraints) { 46 | final size = Size(constraints.maxWidth, constraints.maxHeight); 47 | 48 | return Stack( 49 | children: [ 50 | Positioned.fill( 51 | child: Image.asset( 52 | "assets/images/example-photo-1.jpeg", 53 | fit: BoxFit.contain, 54 | ), 55 | ), 56 | Positioned( 57 | left: size.width * 0.181, 58 | top: size.height * 0.322, 59 | child: FractionalTranslation( 60 | translation: const Offset(-0.5, -0.5), 61 | child: Leader( 62 | link: _leaderLink, 63 | child: Container( 64 | width: 35, 65 | height: 35, 66 | decoration: BoxDecoration( 67 | shape: BoxShape.circle, 68 | color: Colors.grey.shade700, 69 | ), 70 | ), 71 | ), 72 | ), 73 | ), 74 | ], 75 | ); 76 | }, 77 | ), 78 | ), 79 | ), 80 | Follower.withAligner( 81 | link: _leaderLink, 82 | aligner: _aligner, 83 | repaintWhenLeaderChanges: true, 84 | showDebugPaint: false, 85 | child: CupertinoPopoverToolbar( 86 | focalPoint: _focalPoint, 87 | children: [ 88 | TextButton( 89 | // ignore: avoid_print 90 | onPressed: () => print("one"), 91 | child: const Text("One", style: TextStyle(color: Colors.white)), 92 | ), 93 | TextButton( 94 | // ignore: avoid_print 95 | onPressed: () => print("two"), 96 | child: const Text("Two", style: TextStyle(color: Colors.white)), 97 | ), 98 | TextButton( 99 | // ignore: avoid_print 100 | onPressed: () => print("three"), 101 | child: const Text("Three", style: TextStyle(color: Colors.white)), 102 | ), 103 | TextButton( 104 | // ignore: avoid_print 105 | onPressed: () => print("four"), 106 | child: const Text("Four", style: TextStyle(color: Colors.white)), 107 | ), 108 | ], 109 | ), 110 | ), 111 | ], 112 | ), 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/smoke_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | 5 | void main() { 6 | group("Leaders", () { 7 | testWidgets("build in a widget tree", (widgetTester) async { 8 | final link = LeaderLink(); 9 | await _pumpScaffold( 10 | widgetTester: widgetTester, 11 | child: Center( 12 | child: Leader( 13 | link: link, 14 | child: Container( 15 | width: 25, 16 | height: 25, 17 | color: Colors.red, 18 | ), 19 | ), 20 | ), 21 | ); 22 | 23 | // Reaching this point without an error is the success condition. 24 | }); 25 | 26 | testWidgets("build without a child", (widgetTester) async { 27 | final link = LeaderLink(); 28 | await _pumpScaffold( 29 | widgetTester: widgetTester, 30 | child: Center( 31 | child: Leader( 32 | link: link, 33 | ), 34 | ), 35 | ); 36 | 37 | // Reaching this point without an error is the success condition. 38 | }); 39 | }); 40 | 41 | group("Followers", () { 42 | testWidgets("build in a widget tree", (widgetTester) async { 43 | final link = LeaderLink(); 44 | await _pumpScaffold( 45 | widgetTester: widgetTester, 46 | child: Stack( 47 | children: [ 48 | Center( 49 | child: Leader( 50 | link: link, 51 | child: Container( 52 | width: 25, 53 | height: 25, 54 | color: Colors.red, 55 | ), 56 | ), 57 | ), 58 | Follower.withOffset( 59 | link: link, 60 | offset: const Offset(0, -50), 61 | child: Container( 62 | width: 50, 63 | height: 50, 64 | color: Colors.blue, 65 | ), 66 | ), 67 | ], 68 | ), 69 | ); 70 | 71 | // Reaching this point without an error is the success condition. 72 | }); 73 | 74 | testWidgets("build without a child", (widgetTester) async { 75 | final link = LeaderLink(); 76 | await _pumpScaffold( 77 | widgetTester: widgetTester, 78 | child: Stack( 79 | children: [ 80 | Center( 81 | child: Leader( 82 | link: link, 83 | ), 84 | ), 85 | Follower.withOffset( 86 | link: link, 87 | offset: const Offset(0, -50), 88 | ), 89 | ], 90 | ), 91 | ); 92 | 93 | // Reaching this point without an error is the success condition. 94 | }); 95 | 96 | testWidgets("build when the Leader comes and goes", (widgetTester) async { 97 | final showLeader = ValueNotifier(false); 98 | final link = LeaderLink(); 99 | 100 | await _pumpScaffold( 101 | widgetTester: widgetTester, 102 | child: Stack( 103 | children: [ 104 | Center( 105 | child: ValueListenableBuilder( 106 | valueListenable: showLeader, 107 | builder: (context, value, child) { 108 | // Don't build the Leader. Make the Follower and orphan. 109 | if (!showLeader.value) { 110 | return const SizedBox(); 111 | } 112 | 113 | // Build the Leader. 114 | return Leader( 115 | link: link, 116 | ); 117 | }), 118 | ), 119 | Follower.withOffset( 120 | link: link, 121 | offset: const Offset(0, -50), 122 | ), 123 | ], 124 | ), 125 | ); 126 | 127 | // Switch the value to show the Leader and rebuild. 128 | showLeader.value = true; 129 | await widgetTester.pumpAndSettle(); 130 | 131 | // Switch the value to get rid of the Leader and rebuild. 132 | showLeader.value = false; 133 | await widgetTester.pumpAndSettle(); 134 | 135 | // Reaching this point without an error is the success condition. 136 | }); 137 | }); 138 | } 139 | 140 | Future _pumpScaffold({ 141 | required WidgetTester widgetTester, 142 | required Widget child, 143 | }) async { 144 | await widgetTester.pumpWidget( 145 | MaterialApp( 146 | home: Scaffold( 147 | body: child, 148 | ), 149 | ), 150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /test_goldens/follower_fade_out_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 4 | import 'package:follow_the_leader/follow_the_leader.dart'; 5 | 6 | import 'goldens/tools/test_leader_and_follower_scaffold.dart'; 7 | 8 | void main() { 9 | group("Follower >", () { 10 | testGoldenScene("fades out beyond boundary", (tester) async { 11 | final boundaryType = _fadeOutBoundaryVariant.currentValue!; 12 | final boundaryName = boundaryType.name; 13 | 14 | final link = LeaderLink(); 15 | final slideAnimation = AnimationController( 16 | vsync: tester, 17 | duration: const Duration(milliseconds: 300), 18 | ); 19 | 20 | await Timeline( 21 | "Follower Fades Out When Leader Exceeds Boundary ($boundaryName)", 22 | fileName: "follower_fade-out_beyond-$boundaryName", 23 | windowSize: const Size(150, 150), 24 | itemScaffold: minimalTimelineItemScaffold, 25 | layout: const AnimationTimelineSceneLayout( 26 | rowBreakPolicy: AnimationTimelineRowBreak.beforeItemDescription("Start"), 27 | ), 28 | ) // 29 | .setupWithWidget(_buildScaffold( 30 | link, 31 | slideAnimation, 32 | maxLeaderAlignment: boundaryType.maxLeaderAlignment, 33 | boundary: boundaryType.boundary, 34 | )) 35 | .takePhoto("Start") 36 | .modifyScene((tester, _) async { 37 | slideAnimation.forward(); 38 | }) // 39 | .takePhotos(13, const Duration(milliseconds: 50)) 40 | .takePhoto("End") 41 | .modifyScene((tester, _) async { 42 | slideAnimation.reverse(); 43 | }) // 44 | .takePhoto("Start") 45 | .takePhotos(7, const Duration(milliseconds: 50)) 46 | .takePhoto("End") 47 | .run(tester); 48 | }, variant: _fadeOutBoundaryVariant); 49 | 50 | testGoldenScene("does not fade with partial overlap", (tester) async { 51 | final link = LeaderLink(); 52 | final slideAnimation = AnimationController( 53 | vsync: tester, 54 | duration: const Duration(milliseconds: 300), 55 | ); 56 | 57 | await Timeline( 58 | "Follower Does Not Fade When Leader Has Partial Overlap", 59 | fileName: "follower_fade-out_does-not-fade-with-partial-overlap", 60 | windowSize: const Size(150, 150), 61 | itemScaffold: minimalTimelineItemScaffold, 62 | layout: const AnimationTimelineSceneLayout(), 63 | ) // 64 | .setupWithWidget(_buildScaffold( 65 | link, 66 | slideAnimation, 67 | maxLeaderAlignment: const Alignment(0.37, 0), 68 | boundary: WidgetFollowerBoundary(boundaryKey: GlobalKey()), 69 | )) 70 | .takePhoto("Start") 71 | .modifyScene((tester, _) async { 72 | slideAnimation.forward(); 73 | }) // 74 | .takePhotos(10, const Duration(milliseconds: 50)) 75 | .takePhoto("End") 76 | .run(tester); 77 | }); 78 | }); 79 | } 80 | 81 | Widget _buildScaffold( 82 | LeaderLink link, 83 | AnimationController slideAnimation, { 84 | Alignment maxLeaderAlignment = const Alignment(1.15, 0), 85 | FollowerBoundary boundary = const ScreenFollowerBoundary(), 86 | }) { 87 | return AnimatedBuilder( 88 | animation: slideAnimation, 89 | builder: (context, child) { 90 | return buildLeaderAndFollowerWithOffset( 91 | followerAlignBottom, 92 | link: link, 93 | leaderAlignment: Alignment.lerp(Alignment.center, maxLeaderAlignment, slideAnimation.value)!, 94 | boundary: boundary, 95 | fadeOutBeyondBoundary: true, 96 | ); 97 | }, 98 | ); 99 | } 100 | 101 | final _fadeOutBoundaryVariant = ValueVariant(_FadeOutBoundaryVariant.values.toSet()); 102 | 103 | enum _FadeOutBoundaryVariant { 104 | screen, 105 | widget; 106 | 107 | String get name => switch (this) { 108 | _FadeOutBoundaryVariant.screen => "screen", 109 | _FadeOutBoundaryVariant.widget => "widget", 110 | }; 111 | 112 | FollowerBoundary get boundary => switch (this) { 113 | _FadeOutBoundaryVariant.screen => const ScreenFollowerBoundary(), 114 | _FadeOutBoundaryVariant.widget => WidgetFollowerBoundary(boundaryKey: GlobalKey(debugLabel: "widget-boundary")), 115 | }; 116 | 117 | Alignment get maxLeaderAlignment => switch (this) { 118 | _FadeOutBoundaryVariant.screen => const Alignment(1.15, 0), 119 | _FadeOutBoundaryVariant.widget => const Alignment(0.5, 0), 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /example/lib/demo_page_list_viewport.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | import 'package:overlord/follow_the_leader.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | import 'package:page_list_viewport/page_list_viewport.dart'; 6 | 7 | class PageListViewportDemo extends StatefulWidget { 8 | const PageListViewportDemo({Key? key}) : super(key: key); 9 | 10 | @override 11 | State createState() => _PageListViewportDemoState(); 12 | } 13 | 14 | class _PageListViewportDemoState extends State with SingleTickerProviderStateMixin { 15 | late final PageListViewportController _controller; 16 | final _leaderLink = LeaderLink(); 17 | 18 | late final _aligner = CupertinoPopoverToolbarAligner(); 19 | late final _focalPoint = LeaderMenuFocalPoint(link: _leaderLink); 20 | 21 | @override 22 | void initState() { 23 | super.initState(); 24 | _controller = PageListViewportController(vsync: this); 25 | } 26 | 27 | @override 28 | void dispose() { 29 | _controller.dispose(); 30 | super.dispose(); 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | return Scaffold( 36 | body: BuildInOrder( 37 | children: [ 38 | PageListViewportGestures( 39 | controller: _controller, 40 | child: PageListViewport( 41 | controller: _controller, 42 | naturalPageSize: const Size(1920, 2400), 43 | pageCount: 10, 44 | builder: (context, pageIndex) { 45 | final image = Image.asset( 46 | "assets/images/example-photo-1.jpeg", 47 | fit: BoxFit.contain, 48 | ); 49 | 50 | if (pageIndex != 1) { 51 | return image; 52 | } 53 | 54 | return LayoutBuilder( 55 | builder: (context, constraints) { 56 | final size = Size(constraints.maxWidth, constraints.maxHeight); 57 | 58 | return Stack( 59 | children: [ 60 | Positioned.fill( 61 | child: image, 62 | ), 63 | Positioned( 64 | left: size.width * 0.181, 65 | top: size.height * 0.322, 66 | child: FractionalTranslation( 67 | translation: const Offset(-0.5, -0.5), 68 | child: Leader( 69 | link: _leaderLink, 70 | child: Container( 71 | width: 35, 72 | height: 35, 73 | decoration: BoxDecoration( 74 | shape: BoxShape.circle, 75 | color: Colors.grey.shade700, 76 | ), 77 | ), 78 | ), 79 | ), 80 | ), 81 | ], 82 | ); 83 | }, 84 | ); 85 | }, 86 | ), 87 | ), 88 | Follower.withAligner( 89 | link: _leaderLink, 90 | aligner: _aligner, 91 | repaintWhenLeaderChanges: true, 92 | showDebugPaint: false, 93 | child: CupertinoPopoverToolbar( 94 | focalPoint: _focalPoint, 95 | children: [ 96 | TextButton( 97 | // ignore: avoid_print 98 | onPressed: () => print("one"), 99 | child: const Text("One", style: TextStyle(color: Colors.white)), 100 | ), 101 | TextButton( 102 | // ignore: avoid_print 103 | onPressed: () => print("two"), 104 | child: const Text("Two", style: TextStyle(color: Colors.white)), 105 | ), 106 | TextButton( 107 | // ignore: avoid_print 108 | onPressed: () => print("three"), 109 | child: const Text("Three", style: TextStyle(color: Colors.white)), 110 | ), 111 | TextButton( 112 | // ignore: avoid_print 113 | onPressed: () => print("four"), 114 | child: const Text("Four", style: TextStyle(color: Colors.white)), 115 | ), 116 | ], 117 | ), 118 | ), 119 | ], 120 | ), 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test_goldens/goldens/tools/test_leader_and_follower_scaffold.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | 4 | Widget buildLeaderAndFollowerWithOffset( 5 | FollowerAlignment align, { 6 | LeaderLink? link, 7 | Size leaderSize = const Size(10, 10), 8 | Alignment leaderAlignment = Alignment.center, 9 | Size followerSize = const Size(20, 20), 10 | FollowerBoundary boundary = const ScreenFollowerBoundary(), 11 | bool fadeOutBeyondBoundary = false, 12 | }) { 13 | link ??= LeaderLink(); 14 | 15 | return ColoredBox( 16 | color: const Color(0xff020817), 17 | child: Stack( 18 | children: [ 19 | if (boundary is WidgetFollowerBoundary) // 20 | Positioned.fill( 21 | child: Padding( 22 | padding: const EdgeInsets.all(48), 23 | child: DecoratedBox( 24 | key: boundary.boundaryKey, 25 | decoration: BoxDecoration( 26 | border: Border.all(color: Colors.purpleAccent, width: 2), 27 | ), 28 | ), 29 | ), 30 | ), 31 | Align( 32 | alignment: leaderAlignment, 33 | child: Leader( 34 | link: link, 35 | child: _TestLeader(leaderSize), 36 | ), 37 | ), 38 | FollowerFadeOutBeyondBoundary( 39 | link: link, 40 | boundary: boundary, 41 | enabled: fadeOutBeyondBoundary, 42 | child: Follower.withOffset( 43 | link: link, 44 | leaderAnchor: align.leaderAnchor, 45 | followerAnchor: align.followerAnchor, 46 | offset: align.followerOffset, 47 | boundary: boundary, 48 | child: _TestFollower(followerSize), 49 | ), 50 | ), 51 | ], 52 | ), 53 | ); 54 | } 55 | 56 | Widget buildLeaderAndFollowerWithAligner({ 57 | LeaderLink? link, 58 | Size leaderSize = const Size(10, 10), 59 | Alignment leaderAlignment = Alignment.center, 60 | Size followerSize = const Size(20, 20), 61 | required FollowerAligner aligner, 62 | FollowerBoundary boundary = const ScreenFollowerBoundary(), 63 | }) { 64 | link ??= LeaderLink(); 65 | 66 | return ColoredBox( 67 | color: const Color(0xff020817), 68 | child: Stack( 69 | children: [ 70 | Align( 71 | alignment: leaderAlignment, 72 | child: Leader( 73 | link: link, 74 | child: _TestLeader(leaderSize), 75 | ), 76 | ), 77 | Follower.withAligner( 78 | link: link, 79 | aligner: aligner, 80 | boundary: boundary, 81 | child: _TestFollower(followerSize), 82 | ), 83 | ], 84 | ), 85 | ); 86 | } 87 | 88 | class _TestLeader extends StatelessWidget { 89 | const _TestLeader(this.size); 90 | 91 | final Size size; 92 | 93 | @override 94 | Widget build(BuildContext context) { 95 | return Container( 96 | width: size.width, 97 | height: size.height, 98 | color: Colors.blue, 99 | ); 100 | } 101 | } 102 | 103 | class _TestFollower extends StatelessWidget { 104 | const _TestFollower(this.size); 105 | 106 | final Size size; 107 | 108 | @override 109 | Widget build(BuildContext context) { 110 | return Container( 111 | width: size.width, 112 | height: size.height, 113 | color: Colors.red, 114 | ); 115 | } 116 | } 117 | 118 | const followerAlignLeft = FollowerAlignment( 119 | leaderAnchor: Alignment.centerLeft, 120 | followerAnchor: Alignment.centerRight, 121 | followerOffset: Offset(-20, 0), 122 | ); 123 | 124 | const followerAlignTopLeft = FollowerAlignment( 125 | leaderAnchor: Alignment.topLeft, 126 | followerAnchor: Alignment.bottomRight, 127 | followerOffset: Offset(-20, -20), 128 | ); 129 | 130 | const followerAlignTop = FollowerAlignment( 131 | leaderAnchor: Alignment.topCenter, 132 | followerAnchor: Alignment.bottomCenter, 133 | followerOffset: Offset(0, -20), 134 | ); 135 | 136 | const followerAlignTopRight = FollowerAlignment( 137 | leaderAnchor: Alignment.topRight, 138 | followerAnchor: Alignment.bottomLeft, 139 | followerOffset: Offset(20, -20), 140 | ); 141 | 142 | const followerAlignRight = FollowerAlignment( 143 | leaderAnchor: Alignment.centerRight, 144 | followerAnchor: Alignment.centerLeft, 145 | followerOffset: Offset(20, 0), 146 | ); 147 | 148 | const followerAlignBottomRight = FollowerAlignment( 149 | leaderAnchor: Alignment.bottomRight, 150 | followerAnchor: Alignment.topLeft, 151 | followerOffset: Offset(20, 20), 152 | ); 153 | 154 | const followerAlignBottom = FollowerAlignment( 155 | leaderAnchor: Alignment.bottomCenter, 156 | followerAnchor: Alignment.topCenter, 157 | followerOffset: Offset(0, 20), 158 | ); 159 | 160 | const followerAlignBottomLeft = FollowerAlignment( 161 | leaderAnchor: Alignment.bottomLeft, 162 | followerAnchor: Alignment.topRight, 163 | followerOffset: Offset(-20, 20), 164 | ); 165 | 166 | const followerAlignCenter = FollowerAlignment( 167 | leaderAnchor: Alignment.center, 168 | followerAnchor: Alignment.center, 169 | followerOffset: Offset.zero, 170 | ); 171 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/basics/static_positioning.dart'; 2 | import 'package:example/demo_hover.dart'; 3 | import 'package:example/demo_interactive_viewer.dart'; 4 | import 'package:example/demo_page_list_viewport.dart'; 5 | import 'package:example/demo_scaling.dart'; 6 | import 'package:example/demo_scrollables.dart'; 7 | import 'package:example/kitchen_sink/demo_kitchen_sink.dart'; 8 | import 'package:flutter/material.dart'; 9 | import 'package:follow_the_leader/follow_the_leader.dart'; 10 | 11 | import 'demo_orbiting_circles.dart'; 12 | 13 | void main() { 14 | FtlLogs.initLoggers({ 15 | FtlLogs.leader, 16 | FtlLogs.follower, 17 | FtlLogs.link, 18 | // FtlLogs.boundary, 19 | // appLog, 20 | }); 21 | runApp(const MyApp()); 22 | } 23 | 24 | class MyApp extends StatelessWidget { 25 | const MyApp({Key? key}) : super(key: key); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return MaterialApp( 30 | title: 'Follow the Leader Example', 31 | theme: ThemeData.dark(), 32 | debugShowCheckedModeBanner: false, 33 | home: const ExampleApp(), 34 | ); 35 | } 36 | } 37 | 38 | class ExampleApp extends StatefulWidget { 39 | const ExampleApp({Key? key}) : super(key: key); 40 | 41 | @override 42 | State createState() => _ExampleAppState(); 43 | } 44 | 45 | class _ExampleAppState extends State { 46 | final _scaffoldKey = GlobalKey(); 47 | 48 | _MenuItem _selectedMenu = _items.first; 49 | 50 | void _closeDrawer() { 51 | if (_scaffoldKey.currentState!.isDrawerOpen) { 52 | Navigator.of(context).pop(); 53 | } 54 | } 55 | 56 | @override 57 | Widget build(BuildContext context) { 58 | return Scaffold( 59 | key: _scaffoldKey, 60 | appBar: AppBar( 61 | backgroundColor: Colors.transparent, 62 | elevation: 0, 63 | ), 64 | extendBodyBehindAppBar: true, 65 | body: Builder(builder: (bodyContext) { 66 | // Use an intermediate Builder so that the BuildContext that we 67 | // give to the pageBuilder has finite layout bounds. 68 | return _selectedMenu.pageBuilder(bodyContext); 69 | }), 70 | drawer: _buildDrawer(), 71 | ); 72 | } 73 | 74 | Widget _buildDrawer() { 75 | return Drawer( 76 | child: SingleChildScrollView( 77 | primary: false, 78 | child: Padding( 79 | padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), 80 | child: Column( 81 | crossAxisAlignment: CrossAxisAlignment.start, 82 | children: [ 83 | for (final item in _items) ...[ 84 | _DrawerButton( 85 | title: item.title, 86 | onPressed: () => setState(() { 87 | _selectedMenu = item; 88 | _closeDrawer(); 89 | }), 90 | isSelected: _selectedMenu == item, 91 | ), 92 | const SizedBox(height: 24), 93 | ], 94 | ], 95 | ), 96 | ), 97 | ), 98 | ); 99 | } 100 | } 101 | 102 | final _items = [ 103 | _MenuItem( 104 | title: 'Static Positioning', 105 | pageBuilder: (context) { 106 | return const StaticPositioningDemo(); 107 | }, 108 | ), 109 | _MenuItem( 110 | title: 'Follow the Leader', 111 | pageBuilder: (context) { 112 | return const KitchenSinkDemo(); 113 | }, 114 | ), 115 | _MenuItem( 116 | title: 'Page List Viewport', 117 | pageBuilder: (context) => const PageListViewportDemo(), 118 | ), 119 | _MenuItem( 120 | title: 'Interactive Viewer', 121 | pageBuilder: (context) => const InteractiveViewerDemo(), 122 | ), 123 | _MenuItem( 124 | title: 'Hover', 125 | pageBuilder: (context) => const HoverDemo(), 126 | ), 127 | _MenuItem( 128 | title: 'Orbiting Circles', 129 | pageBuilder: (context) => const OrbitingCirclesDemo(), 130 | ), 131 | _MenuItem( 132 | title: 'Scaling', 133 | pageBuilder: (context) => const ScalingDemo(), 134 | ), 135 | _MenuItem( 136 | title: 'Scrollables', 137 | pageBuilder: (context) => const ScrollablesDemo(), 138 | ), 139 | ]; 140 | 141 | class _DrawerButton extends StatelessWidget { 142 | const _DrawerButton({ 143 | Key? key, 144 | required this.title, 145 | this.isSelected = false, 146 | required this.onPressed, 147 | }) : super(key: key); 148 | 149 | final String title; 150 | final bool isSelected; 151 | final VoidCallback onPressed; 152 | 153 | @override 154 | Widget build(BuildContext context) { 155 | return SizedBox( 156 | width: double.infinity, 157 | child: ElevatedButton( 158 | style: ButtonStyle( 159 | backgroundColor: WidgetStateColor.resolveWith((states) { 160 | if (isSelected) { 161 | return const Color(0xFFBBBBBB); 162 | } 163 | 164 | if (states.contains(WidgetState.hovered)) { 165 | return Colors.grey.withValues(alpha: 0.1); 166 | } 167 | 168 | return Colors.transparent; 169 | }), 170 | foregroundColor: 171 | WidgetStateColor.resolveWith((states) => isSelected ? Colors.white : const Color(0xFFBBBBBB)), 172 | elevation: WidgetStateProperty.resolveWith((states) => 0), 173 | padding: WidgetStateProperty.resolveWith((states) => const EdgeInsets.all(16))), 174 | onPressed: isSelected ? null : onPressed, 175 | child: Center(child: Text(title)), 176 | ), 177 | ); 178 | } 179 | } 180 | 181 | class _MenuItem { 182 | const _MenuItem({ 183 | required this.title, 184 | required this.pageBuilder, 185 | }); 186 | 187 | final String title; 188 | final WidgetBuilder pageBuilder; 189 | } 190 | -------------------------------------------------------------------------------- /lib/src/leader_link.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/scheduler.dart'; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:follow_the_leader/src/leader.dart'; 5 | import 'package:vector_math/vector_math_64.dart'; 6 | 7 | /// Links one or more [Follower] positions to a [Leader]. 8 | class LeaderLink with ChangeNotifier { 9 | /// Whether a [LeaderLayer] is currently connected to this link. 10 | bool get leaderConnected => _leader != null; 11 | 12 | LeaderLayer? get leader => _leader; 13 | LeaderLayer? _leader; 14 | set leader(LeaderLayer? newLeader) { 15 | if (newLeader == _leader) { 16 | return; 17 | } 18 | 19 | _leader = newLeader; 20 | } 21 | 22 | /// Transform that maps a coordinate in screen-space to a coordinate 23 | /// in leader space. 24 | Matrix4? screenToLeader; 25 | 26 | /// Transform that maps a coordinate in leader-space to a coordinate 27 | /// in screen space. 28 | Matrix4? leaderToScreen; 29 | 30 | /// The bounds of the leader's child widget in leader-space. 31 | /// 32 | /// For example, if the leader's child sits at the leader's origin, 33 | /// the top-left of this [Rect] will be (0, 0). 34 | Rect? leaderContentBoundsInLeaderSpace; 35 | 36 | /// Global offset for the top-left corner of the [Leader]'s content. 37 | Offset? get offset => _offset; 38 | Offset? _offset; 39 | set offset(Offset? newOffset) { 40 | if (newOffset == _offset) { 41 | return; 42 | } 43 | 44 | _offset = newOffset; 45 | notifyListeners(); 46 | } 47 | 48 | /// The current scale of the [Leader]. 49 | double? get scale => _scale; 50 | double? _scale; 51 | set scale(double? newScale) { 52 | if (newScale == _scale) { 53 | return; 54 | } 55 | 56 | _scale = newScale; 57 | notifyListeners(); 58 | } 59 | 60 | /// The size of the content of the connected [LeaderLayer]. 61 | /// 62 | /// This is the un-scaled size of the [Leader] widget. The final [Leader] 63 | /// painting might be scaled up or down from this point. See [scale] for 64 | /// access to that information. 65 | /// 66 | /// Generally this should be set by the [RenderObject] that paints on the 67 | /// registered [LeaderLayer] (for instance a [RenderLeaderLayer] that shares 68 | /// this link with its followers). This size may be outdated before and during 69 | /// layout. 70 | Size? get leaderSize => _leaderSize; 71 | Size? _leaderSize; 72 | set leaderSize(Size? newSize) { 73 | if (newSize == _leaderSize) { 74 | return; 75 | } 76 | 77 | _leaderSize = newSize; 78 | notifyListeners(); 79 | } 80 | 81 | Offset? getOffsetInLeader(Alignment alignment) { 82 | if (_offset == null || _leaderSize == null || _scale == null) { 83 | return null; 84 | } 85 | 86 | final leaderOriginOnScreenVec = leaderToScreen!.transform3(Vector3.zero()); 87 | final leaderOriginOnScreen = Offset(leaderOriginOnScreenVec.x, leaderOriginOnScreenVec.y); 88 | final offsetInLeader = alignment.alongSize(leaderSize! * scale!); 89 | return leaderOriginOnScreen + offsetInLeader; 90 | } 91 | 92 | bool get hasFollowers => _connectedFollowers > 0; 93 | int _connectedFollowers = 0; 94 | 95 | /// Called by the [FollowerLayer] to establish a link to a [LeaderLayer]. 96 | /// 97 | /// The returned [LayerLinkHandle] provides access to the leader via 98 | /// [LayerLinkHandle.leader]. 99 | /// 100 | /// When the [FollowerLayer] no longer wants to follow the [LeaderLayer], 101 | /// [LayerLinkHandle.dispose] must be called to disconnect the link. 102 | CustomLayerLinkHandle registerFollower() { 103 | assert(_connectedFollowers >= 0); 104 | _connectedFollowers++; 105 | return CustomLayerLinkHandle(this); 106 | } 107 | 108 | @override 109 | void notifyListeners() { 110 | if (WidgetsBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { 111 | // We're in the middle of a layout and paint phase. Notify listeners 112 | // at the end of the frame. 113 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 114 | super.notifyListeners(); 115 | }); 116 | return; 117 | } 118 | 119 | // We're not in a layout/paint phase. Immediately notify listeners. 120 | super.notifyListeners(); 121 | } 122 | 123 | final _onFollowerTransformChangeListeners = {}; 124 | 125 | /// Adds a listener that's notified when the [FollowerLayer]'s transform changes. 126 | /// 127 | /// The [FollowerLayer] might change its transform without any intervention or 128 | /// knowledge of the owning `RenderObject`, because the transform depends upon the 129 | /// scene of layers, which Flutter might re-compose on its own. Therefore, if 130 | /// an object needs to know any time that the [FollowerLayer] changes its transform, 131 | /// it can register a listener. 132 | /// 133 | /// This listener system was originally added so that the [RenderFollower] `RenderObject` 134 | /// repaints whenever its [FollowerLayer] moves. This solves, for example, a bug where 135 | /// the first frame of an iOS toolbar pointed its arrow towards the wrong offset. 136 | void addFollowerLayerTransformChangeListener(VoidCallback listener) { 137 | _onFollowerTransformChangeListeners.add(listener); 138 | } 139 | 140 | /// Removes a listener that was previously added by [addFollowerLayerTransformChangeListener]. 141 | void removeFollowerLayerTransformChangeListener(VoidCallback listener) { 142 | _onFollowerTransformChangeListeners.remove(listener); 143 | } 144 | 145 | /// Notify listeners that the [FollowerLayer]'s transform has changed - this should 146 | /// only be called by the [FollowerLayer] when it recognizes that its transform 147 | /// has changed from one value to a different value. 148 | void notifyListenersOfFollowerLayerTransformChange() { 149 | for (final listener in _onFollowerTransformChangeListeners) { 150 | listener(); 151 | } 152 | } 153 | 154 | @override 155 | String toString() => '${describeIdentity(this)}(${leader != null ? "" : ""})'; 156 | } 157 | 158 | /// A handle provided by [LeaderLink.registerFollower] to a calling 159 | /// [FollowerLayer] to establish a link between that [FollowerLayer] and a 160 | /// [LeaderLayer]. 161 | /// 162 | /// If the link is no longer needed, [dispose] must be called to disconnect it. 163 | class CustomLayerLinkHandle { 164 | CustomLayerLinkHandle(this._link); 165 | 166 | LeaderLink? _link; 167 | 168 | /// The currently-registered [LeaderLayer], if any. 169 | LeaderLayer? get leader => _link!.leader; 170 | 171 | /// Disconnects the link between the [FollowerLayer] owning this handle and 172 | /// the [leader]. 173 | /// 174 | /// The [LayerLinkHandle] becomes unusable after calling this method. 175 | void dispose() { 176 | assert(_link!._connectedFollowers > 0); 177 | _link!._connectedFollowers--; 178 | _link = null; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /example/lib/kitchen_sink/demo_kitchen_sink.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/kitchen_sink/kitchen_sink_desktop.dart'; 2 | import 'package:example/kitchen_sink/kitchen_sink_mobile.dart'; 3 | import 'package:example/kitchen_sink/pin_and_follower_drag_arena.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:follow_the_leader/follow_the_leader.dart'; 6 | 7 | class KitchenSinkDemo extends StatefulWidget { 8 | const KitchenSinkDemo({Key? key}) : super(key: key); 9 | 10 | @override 11 | State createState() => _KitchenSinkDemoState(); 12 | } 13 | 14 | class _KitchenSinkDemoState extends State { 15 | final _controller = KitchenSinkDemoController( 16 | screenBoundsKey: GlobalKey(), 17 | widgetBoundsKey: GlobalKey(), 18 | ); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return Theme( 23 | data: ThemeData.dark(), 24 | child: Builder(builder: (context) { 25 | return Material( 26 | color: HSVColor.fromColor(Theme.of(context).cardColor).withValue(0.08).toColor(), 27 | child: LayoutBuilder(builder: (context, constraints) { 28 | final isMobile = constraints.maxWidth < 600; 29 | 30 | return isMobile // 31 | ? KitchenSinkMobileScaffold( 32 | controller: _controller, 33 | child: _buildPinArena(isMobile: true), 34 | ) 35 | : KitchenSinkDesktopScaffold( 36 | controller: _controller, 37 | child: _buildPinArena(isMobile: false), 38 | ); 39 | }), 40 | ); 41 | }), 42 | ); 43 | } 44 | 45 | Widget _buildPinArena({ 46 | required bool isMobile, 47 | }) { 48 | return ListenableBuilder( 49 | listenable: _controller.config, 50 | builder: (context, child) { 51 | return PinAndFollowerDragArena( 52 | boundsInsets: isMobile // 53 | ? const EdgeInsets.only(left: 48, right: 48, top: 75, bottom: 48) 54 | : const EdgeInsets.only(left: 100, right: 100, top: 75, bottom: 175), 55 | screenBoundsKey: _controller.screenBoundsKey, 56 | widgetBoundsKey: _controller.widgetBoundsKey, 57 | config: _controller.config.value, 58 | ); 59 | }); 60 | } 61 | } 62 | 63 | class KitchenSinkDemoController { 64 | KitchenSinkDemoController({ 65 | required this.screenBoundsKey, 66 | required this.widgetBoundsKey, 67 | }); 68 | 69 | final GlobalKey screenBoundsKey; 70 | final GlobalKey widgetBoundsKey; 71 | 72 | final config = ValueNotifier(const FollowerDemoConfiguration( 73 | followerDirection: FollowerDirection.up, 74 | followerConstraints: FollowerConstraint.keyboardAndScreen, 75 | followerMenuType: FollowerMenuType.smallPopover, 76 | fadeBeyondBoundary: false, 77 | )); 78 | 79 | void onNoLimitsTap() { 80 | config.value = config.value 81 | .copyWith( 82 | followerConstraints: FollowerConstraint.none, 83 | ) 84 | .clearBoundary() 85 | .clearBoundaryKey(); 86 | 87 | configureToolbarAligner(config.value.followerDirection); 88 | } 89 | 90 | void onScreenBoundsTap() { 91 | config.value = config.value.copyWith( 92 | followerConstraints: FollowerConstraint.screen, 93 | followerBoundary: const ScreenFollowerBoundary(), 94 | boundaryKey: screenBoundsKey, 95 | ); 96 | 97 | configureToolbarAligner(config.value.followerDirection); 98 | } 99 | 100 | void onSafeAreaBoundsTap() { 101 | config.value = config.value 102 | .copyWith( 103 | followerConstraints: FollowerConstraint.safeArea, 104 | followerBoundary: const SafeAreaFollowerBoundary(), 105 | ) 106 | .clearBoundaryKey(); 107 | 108 | configureToolbarAligner(config.value.followerDirection); 109 | } 110 | 111 | void onKeyboardOnlyBoundsTap() { 112 | config.value = config.value 113 | .copyWith( 114 | followerConstraints: FollowerConstraint.keyboardOnly, 115 | followerBoundary: const KeyboardFollowerBoundary(keepWithinScreen: false), 116 | ) 117 | .clearBoundaryKey(); 118 | 119 | configureToolbarAligner(config.value.followerDirection); 120 | } 121 | 122 | void onKeyboardAndScreenBoundsTap() { 123 | config.value = config.value 124 | .copyWith( 125 | followerConstraints: FollowerConstraint.keyboardAndScreen, 126 | followerBoundary: const KeyboardFollowerBoundary(), 127 | ) 128 | .clearBoundaryKey(); 129 | 130 | configureToolbarAligner(config.value.followerDirection); 131 | } 132 | 133 | void onWidgetBoundsTap() { 134 | config.value = config.value.copyWith( 135 | followerConstraints: FollowerConstraint.widget, 136 | followerBoundary: WidgetFollowerBoundary(boundaryKey: widgetBoundsKey), 137 | boundaryKey: widgetBoundsKey, 138 | ); 139 | 140 | configureToolbarAligner(config.value.followerDirection); 141 | } 142 | 143 | void toggleFadeBeyondBoundary() { 144 | config.value = config.value.copyWith( 145 | fadeBeyondBoundary: !config.value.fadeBeyondBoundary, 146 | ); 147 | } 148 | 149 | void useGenericMenu() { 150 | config.value = config.value.copyWith( 151 | followerMenuType: FollowerMenuType.smallPopover, 152 | ); 153 | 154 | configureToolbarAligner(config.value.followerDirection); 155 | } 156 | 157 | void useIOSToolbar() { 158 | config.value = config.value.copyWith( 159 | followerMenuType: FollowerMenuType.iOSToolbar, 160 | ); 161 | 162 | configureToolbarAligner(config.value.followerDirection); 163 | } 164 | 165 | void useIOSPopover() { 166 | config.value = config.value.copyWith( 167 | followerMenuType: FollowerMenuType.iOSMenu, 168 | ); 169 | 170 | configureToolbarAligner(config.value.followerDirection); 171 | } 172 | 173 | void useAndroidSpellcheck() { 174 | config.value = config.value.copyWith( 175 | followerMenuType: FollowerMenuType.androidSpellcheck, 176 | ); 177 | 178 | configureToolbarAligner(config.value.followerDirection); 179 | } 180 | 181 | void configureToolbarAligner(FollowerDirection direction) { 182 | if (direction == FollowerDirection.automatic) { 183 | switch (config.value.followerMenuType) { 184 | case FollowerMenuType.iOSToolbar: 185 | config.value = config.value.copyWith( 186 | followerDirection: direction, 187 | aligner: PreferredPositionAligner.top(), 188 | ); 189 | break; 190 | case FollowerMenuType.smallPopover: 191 | case FollowerMenuType.iOSMenu: 192 | config.value = config.value.copyWith( 193 | followerDirection: direction, 194 | aligner: PreferredPositionAligner.left(), 195 | ); 196 | break; 197 | case FollowerMenuType.androidSpellcheck: 198 | config.value = config.value.copyWith( 199 | followerDirection: direction, 200 | aligner: PreferredPositionAligner.bottom(), 201 | ); 202 | break; 203 | } 204 | } else { 205 | config.value = config.value.copyWith( 206 | followerDirection: direction, 207 | )..clearAligner(); 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /lib/src/aligners.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/painting.dart'; 2 | 3 | import 'package:follow_the_leader/src/follower.dart'; 4 | 5 | /// A [FollowerAligner] that attempts to position the [Follower] at a preferred position in 6 | /// relation to the [Leader], but moves the [Follower] if there's not enough space between 7 | /// the [Leader] and the boundary. 8 | /// 9 | /// If there's not enough room in the main-axis direction then the [Follower] is flipped to the 10 | /// opposite side of the [Leader]. 11 | /// 12 | /// If there's not enough room in the cross-axis direction, the [Follower] is nudged by just 13 | /// enough distance to keep it within the boundary. 14 | /// 15 | /// If no boundary is registered with the [Follower], this aligner always positions the [Follower] 16 | /// at the desired location relative to the [Leader]. 17 | class PreferredPositionAligner implements FollowerAligner { 18 | PreferredPositionAligner.top({ 19 | this.leaderCrossAxisAnchor = Alignment.topCenter, 20 | this.followerCrossAxisAnchor = Alignment.bottomCenter, 21 | this.gap = 20, 22 | }) : followerPosition = PreferredPrimaryPosition.top; 23 | 24 | PreferredPositionAligner.bottom({ 25 | this.leaderCrossAxisAnchor = Alignment.bottomCenter, 26 | this.followerCrossAxisAnchor = Alignment.topCenter, 27 | this.gap = 20, 28 | }) : followerPosition = PreferredPrimaryPosition.bottom; 29 | 30 | PreferredPositionAligner.left({ 31 | this.leaderCrossAxisAnchor = Alignment.centerLeft, 32 | this.followerCrossAxisAnchor = Alignment.centerRight, 33 | this.gap = 20, 34 | }) : followerPosition = PreferredPrimaryPosition.left; 35 | 36 | PreferredPositionAligner.right({ 37 | this.leaderCrossAxisAnchor = Alignment.centerRight, 38 | this.followerCrossAxisAnchor = Alignment.centerLeft, 39 | this.gap = 20, 40 | }) : followerPosition = PreferredPrimaryPosition.right; 41 | 42 | PreferredPositionAligner({ 43 | required this.followerPosition, 44 | required this.leaderCrossAxisAnchor, 45 | required this.followerCrossAxisAnchor, 46 | this.gap = 20, 47 | }); 48 | 49 | final PreferredPrimaryPosition followerPosition; 50 | 51 | final Alignment leaderCrossAxisAnchor; 52 | final Alignment followerCrossAxisAnchor; 53 | 54 | final Dip gap; 55 | 56 | @override 57 | FollowerAlignment align(RectDip globalLeaderRect, SizeDip followerSize, [RectDip? globalBounds]) { 58 | if (followerPosition.isVertical) { 59 | return _alignVertical(globalLeaderRect, followerSize, globalBounds); 60 | } else { 61 | return _alignHorizontal(globalLeaderRect, followerSize, globalBounds); 62 | } 63 | } 64 | 65 | FollowerAlignment _alignVertical(RectDip globalLeaderRect, SizeDip followerSize, [RectDip? globalBounds]) { 66 | assert(followerPosition == PreferredPrimaryPosition.top || followerPosition == PreferredPrimaryPosition.bottom); 67 | 68 | final spaceAboveLeader = globalLeaderRect.top - (globalBounds?.top ?? 0); 69 | final spaceBelowLeader = (globalBounds?.bottom ?? double.infinity) - globalLeaderRect.bottom; 70 | final neededFollowerSpace = followerSize.height + gap; 71 | 72 | if (followerPosition == PreferredPrimaryPosition.top) { 73 | // We want to place the Follower above the Leader. 74 | if (spaceAboveLeader < neededFollowerSpace && spaceBelowLeader > neededFollowerSpace) { 75 | // The follower hit the minimum distance. Invert the Follower position to below the Leader. 76 | return _alignBottom(); 77 | } 78 | 79 | // The follower can fit above. Use the standard orientation. 80 | return _alignTop(); 81 | } else { 82 | // We want to place the Follower below the Leader. 83 | if (spaceBelowLeader < neededFollowerSpace && spaceAboveLeader > neededFollowerSpace) { 84 | // The follower hit the minimum distance. Invert the follower position. 85 | return _alignTop(); 86 | } 87 | 88 | // The follower can fit below. Use the standard orientation. 89 | return _alignBottom(); 90 | } 91 | } 92 | 93 | FollowerAlignment _alignHorizontal(RectDip globalLeaderRect, SizeDip followerSize, [RectDip? globalBounds]) { 94 | assert(followerPosition == PreferredPrimaryPosition.left || followerPosition == PreferredPrimaryPosition.right); 95 | 96 | final spaceLeftOfLeader = globalLeaderRect.left - (globalBounds?.left ?? 0); 97 | final spaceRightOfLeader = (globalBounds?.right ?? double.infinity) - globalLeaderRect.right; 98 | final neededFollowerSpace = followerSize.width + gap; 99 | 100 | if (followerPosition == PreferredPrimaryPosition.left) { 101 | // We want to place the Follower to the left of the Leader. 102 | if (spaceLeftOfLeader < neededFollowerSpace && spaceRightOfLeader > neededFollowerSpace) { 103 | // The follower hit the minimum distance. Invert the Follower position to the right. 104 | return _alignRight(); 105 | } 106 | 107 | // The follower can fit to the left. Use the standard orientation. 108 | return _alignLeft(); 109 | } else { 110 | // We want to place the Follower to the right of the Leader. 111 | if (spaceRightOfLeader < neededFollowerSpace && spaceLeftOfLeader > neededFollowerSpace) { 112 | // The follower hit the minimum distance. Invert the follower position to the left. 113 | return _alignLeft(); 114 | } 115 | 116 | // The follower can fit to the right. Use the standard orientation. 117 | return _alignRight(); 118 | } 119 | } 120 | 121 | FollowerAlignment _alignTop() { 122 | return FollowerAlignment( 123 | leaderAnchor: Alignment(leaderCrossAxisAnchor.x, -1), 124 | followerAnchor: Alignment(followerCrossAxisAnchor.x, 1), 125 | followerOffset: Offset(0, -gap), 126 | ); 127 | } 128 | 129 | FollowerAlignment _alignBottom() { 130 | return FollowerAlignment( 131 | leaderAnchor: Alignment(leaderCrossAxisAnchor.x, 1), 132 | followerAnchor: Alignment(followerCrossAxisAnchor.x, -1), 133 | followerOffset: Offset(0, gap), 134 | ); 135 | } 136 | 137 | FollowerAlignment _alignLeft() { 138 | return FollowerAlignment( 139 | leaderAnchor: Alignment(-1, leaderCrossAxisAnchor.y), 140 | followerAnchor: Alignment(1, followerCrossAxisAnchor.y), 141 | followerOffset: Offset(-gap, 0), 142 | ); 143 | } 144 | 145 | FollowerAlignment _alignRight() { 146 | return FollowerAlignment( 147 | leaderAnchor: Alignment(1, leaderCrossAxisAnchor.y), 148 | followerAnchor: Alignment(-1, followerCrossAxisAnchor.y), 149 | followerOffset: Offset(gap, 0), 150 | ); 151 | } 152 | } 153 | 154 | enum PreferredPrimaryPosition { 155 | top, 156 | bottom, 157 | left, 158 | right; 159 | 160 | bool get isVertical => this == top || this == bottom; 161 | 162 | bool get isHorizontal => this == left || this == right; 163 | } 164 | 165 | /// Positions a [Follower] near a [Leader] as per [leaderAnchor], [followerAnchor], and [gap], but constrains 166 | /// the [Follower] within the [Follower]'s bounds by holding the [Follower] at the edge of the 167 | /// boundary, regardless of where the [Leader] is positioned. 168 | /// 169 | /// You can think of this behavior like holding the [Follower] within a fence. 170 | class ConstrainedAligner implements FollowerAligner { 171 | const ConstrainedAligner({ 172 | required this.leaderAnchor, 173 | required this.followerAnchor, 174 | this.gap = Offset.zero, 175 | }); 176 | 177 | final Alignment leaderAnchor; 178 | final Alignment followerAnchor; 179 | final Offset gap; 180 | 181 | @override 182 | FollowerAlignment align(Rect globalLeaderRect, Size followerSize, [Rect? globalBounds]) { 183 | // TODO: implement align 184 | throw UnimplementedError(); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /test_goldens/follower_constraints_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 4 | import 'package:follow_the_leader/follow_the_leader.dart'; 5 | import 'package:super_keyboard/super_keyboard.dart'; 6 | import 'package:super_keyboard/super_keyboard_test.dart'; 7 | 8 | import 'goldens/tools/ftl_gallery_scene.dart'; 9 | import 'goldens/tools/test_leader_and_follower_scaffold.dart'; 10 | 11 | void main() { 12 | group("Follower > boundary constraints >", () { 13 | testGoldenScene("screen", (tester) async { 14 | await Gallery( 15 | "Boundary Constraints (screen)", 16 | fileName: "follower_boundary-constraints_screen", 17 | layout: ftlGridGoldenSceneLayout, 18 | itemConstraints: const BoxConstraints.tightFor(width: 300, height: 300), 19 | itemSetup: (tester) async => tester.pump(), 20 | ) 21 | .itemFromWidget( 22 | description: "Top/Left", 23 | widget: _buildLeaderAndFollower(const Alignment(-1.5, -1.5), followerAlignTopLeft), 24 | ) 25 | .itemFromWidget( 26 | description: "Top", 27 | widget: _buildLeaderAndFollower(const Alignment(0, -1.5), followerAlignTop), 28 | ) 29 | .itemFromWidget( 30 | description: "Top/Right", 31 | widget: _buildLeaderAndFollower(const Alignment(1.5, -1.5), followerAlignTopRight), 32 | ) 33 | .itemFromWidget( 34 | description: "Left", 35 | widget: _buildLeaderAndFollower(const Alignment(-1.5, 0), followerAlignLeft), 36 | ) 37 | .itemFromWidget( 38 | description: "Right", 39 | widget: _buildLeaderAndFollower(const Alignment(1.5, 0), followerAlignRight), 40 | ) 41 | .itemFromWidget( 42 | description: "Bottom/Left", 43 | widget: _buildLeaderAndFollower(const Alignment(-1.5, 1.5), followerAlignBottomLeft), 44 | ) 45 | .itemFromWidget( 46 | description: "Bottom/Right", 47 | widget: _buildLeaderAndFollower(const Alignment(1.5, 1.5), followerAlignBottomRight), 48 | ) 49 | .itemFromWidget( 50 | description: "Bottom", 51 | widget: _buildLeaderAndFollower(const Alignment(0, 1.5), followerAlignBottom), 52 | ) 53 | .run(tester); 54 | }); 55 | 56 | testGoldenSceneOnIOS("software keyboard", (tester) async { 57 | await Gallery( 58 | "Boundary Constraints (keyboard)", 59 | fileName: "follower_boundary-constraints_keyboard", 60 | layout: ftlGridGoldenSceneLayout, 61 | // Item is the size of an iPhone 16 (DIP). 62 | itemConstraints: const BoxConstraints.tightFor(width: 393, height: 852), 63 | itemSetup: (tester) async => tester.pump(), 64 | ) 65 | .itemFromWidget( 66 | description: "Unconstrained", 67 | widget: SoftwareKeyboardHeightSimulator( 68 | tester: tester, 69 | initialKeyboardState: KeyboardState.open, 70 | renderSimulatedKeyboard: true, 71 | child: _buildLeaderAndFollower( 72 | Alignment.center, 73 | followerAlignBottom, 74 | leaderSize: const Size(25, 25), 75 | followerSize: const Size(50, 50), 76 | boundary: const KeyboardFollowerBoundary(), 77 | ), 78 | ), 79 | ) 80 | .itemFromWidget( 81 | description: "Near Keyboard", 82 | widget: SoftwareKeyboardHeightSimulator( 83 | tester: tester, 84 | initialKeyboardState: KeyboardState.open, 85 | renderSimulatedKeyboard: true, 86 | child: _buildLeaderAndFollower( 87 | const Alignment(0, 0.15), 88 | followerAlignBottom, 89 | leaderSize: const Size(25, 25), 90 | followerSize: const Size(50, 50), 91 | boundary: const KeyboardFollowerBoundary(), 92 | ), 93 | ), 94 | ) 95 | .itemFromWidget( 96 | description: "Behind Keyboard", 97 | widget: SoftwareKeyboardHeightSimulator( 98 | tester: tester, 99 | initialKeyboardState: KeyboardState.open, 100 | renderSimulatedKeyboard: true, 101 | child: _buildLeaderAndFollower( 102 | const Alignment(0, 0.5), 103 | followerAlignBottom, 104 | leaderSize: const Size(25, 25), 105 | followerSize: const Size(50, 50), 106 | boundary: const KeyboardFollowerBoundary(), 107 | ), 108 | ), 109 | ) 110 | .run(tester); 111 | }); 112 | 113 | testGoldenScene("widget bounds", (tester) async { 114 | final widgetBoundary = WidgetFollowerBoundary( 115 | boundaryKey: GlobalKey(debugLabel: "widget-boundary"), 116 | ); 117 | 118 | await Gallery( 119 | "Boundary Constraints (widget)", 120 | fileName: "follower_boundary-constraints_widget", 121 | layout: ftlGridGoldenSceneLayout, 122 | itemConstraints: const BoxConstraints.tightFor(width: 300, height: 300), 123 | itemSetup: (tester) async => tester.pump(), 124 | ) 125 | .itemFromWidget( 126 | description: "Top/Left", 127 | widget: _buildLeaderAndFollower( 128 | const Alignment(-1, -1), 129 | followerAlignBottomRight, 130 | boundary: widgetBoundary, 131 | ), 132 | ) 133 | .itemFromWidget( 134 | description: "Top", 135 | widget: _buildLeaderAndFollower( 136 | const Alignment(0, -1), 137 | followerAlignBottom, 138 | boundary: widgetBoundary, 139 | ), 140 | ) 141 | .itemFromWidget( 142 | description: "Top/Right", 143 | widget: _buildLeaderAndFollower( 144 | const Alignment(1, -1), 145 | followerAlignBottomLeft, 146 | boundary: widgetBoundary, 147 | ), 148 | ) 149 | .itemFromWidget( 150 | description: "Left", 151 | widget: _buildLeaderAndFollower( 152 | const Alignment(-1, 0), 153 | followerAlignRight, 154 | boundary: widgetBoundary, 155 | ), 156 | ) 157 | .itemFromWidget( 158 | description: "Right", 159 | widget: _buildLeaderAndFollower( 160 | const Alignment(1, 0), 161 | followerAlignLeft, 162 | boundary: widgetBoundary, 163 | ), 164 | ) 165 | .itemFromWidget( 166 | description: "Bottom/Left", 167 | widget: _buildLeaderAndFollower( 168 | const Alignment(-1, 1), 169 | followerAlignTopRight, 170 | boundary: widgetBoundary, 171 | ), 172 | ) 173 | .itemFromWidget( 174 | description: "Bottom", 175 | widget: _buildLeaderAndFollower( 176 | const Alignment(0, 1), 177 | followerAlignTop, 178 | boundary: widgetBoundary, 179 | ), 180 | ) 181 | .itemFromWidget( 182 | description: "Bottom/Right", 183 | widget: _buildLeaderAndFollower( 184 | const Alignment(1, 1), 185 | followerAlignTopLeft, 186 | boundary: widgetBoundary, 187 | ), 188 | ) 189 | .run(tester); 190 | }); 191 | }); 192 | } 193 | 194 | Widget _buildLeaderAndFollower( 195 | Alignment leaderAlignment, 196 | FollowerAlignment followerAlignment, { 197 | Size leaderSize = const Size(10, 10), 198 | Size followerSize = const Size(20, 20), 199 | FollowerBoundary boundary = const ScreenFollowerBoundary(), 200 | }) { 201 | return buildLeaderAndFollowerWithOffset( 202 | leaderAlignment: leaderAlignment, 203 | leaderSize: leaderSize, 204 | followerAlignment, 205 | followerSize: followerSize, 206 | boundary: boundary, 207 | ); 208 | } 209 | -------------------------------------------------------------------------------- /test_goldens/preferred_position_aligner_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:flutter_test_goldens/flutter_test_goldens.dart'; 4 | import 'package:follow_the_leader/follow_the_leader.dart'; 5 | 6 | import 'goldens/tools/ftl_gallery_scene.dart'; 7 | import 'goldens/tools/test_leader_and_follower_scaffold.dart'; 8 | 9 | void main() { 10 | group("Follower > preferred position aligner >", () { 11 | testGoldenScene("happy path", (tester) async { 12 | await Gallery( 13 | "Preferred Position Aligner - Happy Path", 14 | fileName: "follower_preferred-position-aligner_happy-path", 15 | layout: ftlGridGoldenSceneLayout, 16 | itemConstraints: const BoxConstraints.tightFor(width: 150, height: 150), 17 | itemSetup: (tester) async => tester.pump(), 18 | ) 19 | .itemFromWidget( 20 | description: "Left", 21 | widget: _buildLeaderAndFollower(_left), 22 | ) 23 | .itemFromWidget( 24 | description: "Top", 25 | widget: _buildLeaderAndFollower(_top), 26 | ) 27 | .itemFromWidget( 28 | description: "Right", 29 | widget: _buildLeaderAndFollower(_right), 30 | ) 31 | .itemFromWidget( 32 | description: "Bottom", 33 | widget: _buildLeaderAndFollower(_bottom), 34 | ) 35 | .run(tester); 36 | }); 37 | 38 | testGoldenScene("pushed on the cross-axis", (tester) async { 39 | await Gallery( 40 | "Preferred Position Aligner - Pushed on Cross-Axis", 41 | fileName: "follower_preferred-position-aligner_pushed-on-cross-axis", 42 | layout: ftlGridGoldenSceneLayout, 43 | itemConstraints: const BoxConstraints.tightFor(width: 150, height: 150), 44 | itemSetup: (tester) async => tester.pump(), 45 | ) 46 | .itemFromWidget( 47 | description: "Left (Aligned Bottom)", 48 | widget: _buildLeaderAndFollower( 49 | const _PreferredPosition( 50 | side: PreferredPrimaryPosition.left, 51 | leaderCrossAxisAnchor: Alignment.bottomLeft, 52 | followerCrossAxisAnchor: Alignment.bottomRight, 53 | ), 54 | followerSize: const Size(20, 100), 55 | ), 56 | ) 57 | .itemFromWidget( 58 | description: "Top (Aligned Right)", 59 | // Partial pixel alignment problem. 60 | tolerancePx: 50, 61 | widget: _buildLeaderAndFollower( 62 | const _PreferredPosition( 63 | side: PreferredPrimaryPosition.top, 64 | leaderCrossAxisAnchor: Alignment.topRight, 65 | followerCrossAxisAnchor: Alignment.bottomRight, 66 | ), 67 | followerSize: const Size(100, 20), 68 | ), 69 | ) 70 | .itemFromWidget( 71 | description: "Right (Aligned Top)", 72 | // Partial pixel alignment problem. 73 | tolerancePx: 250, 74 | 75 | widget: _buildLeaderAndFollower( 76 | const _PreferredPosition( 77 | side: PreferredPrimaryPosition.right, 78 | leaderCrossAxisAnchor: Alignment.topRight, 79 | followerCrossAxisAnchor: Alignment.topLeft, 80 | ), 81 | followerSize: const Size(20, 100), 82 | ), 83 | ) 84 | .itemFromWidget( 85 | description: "Bottom (Aligned Left)", 86 | widget: _buildLeaderAndFollower( 87 | const _PreferredPosition( 88 | side: PreferredPrimaryPosition.bottom, 89 | leaderCrossAxisAnchor: Alignment.bottomLeft, 90 | followerCrossAxisAnchor: Alignment.topLeft, 91 | ), 92 | followerSize: const Size(100, 20), 93 | ), 94 | ) 95 | .run(tester); 96 | }); 97 | 98 | testGoldenScene("forced to flip sides", (tester) async { 99 | await Gallery( 100 | "Preferred Position Aligner - Forced to Flip", 101 | fileName: "follower_preferred-position-aligner_forced-to-flip", 102 | layout: ftlGridGoldenSceneLayout, 103 | itemConstraints: const BoxConstraints.tightFor(width: 150, height: 150), 104 | itemSetup: (tester) async => tester.pump(), 105 | ) 106 | .itemFromWidget( 107 | description: "Left (Inverted)", 108 | widget: _buildLeaderAndFollower(_left, leaderAlignment: const Alignment(-0.5, 0)), 109 | ) 110 | .itemFromWidget( 111 | description: "Top (Inverted)", 112 | // Partial pixel alignment problem. 113 | tolerancePx: 100, 114 | widget: _buildLeaderAndFollower(_top, leaderAlignment: const Alignment(0, -0.5)), 115 | ) 116 | .itemFromWidget( 117 | description: "Right (Inverted)", 118 | // Partial pixel alignment problem. 119 | tolerancePx: 100, 120 | widget: _buildLeaderAndFollower(_right, leaderAlignment: const Alignment(0.5, 0)), 121 | ) 122 | .itemFromWidget( 123 | description: "Bottom (Inverted)", 124 | widget: _buildLeaderAndFollower(_bottom, leaderAlignment: const Alignment(0, 0.5)), 125 | ) 126 | .run(tester); 127 | }); 128 | 129 | testGoldenScene("not enough space on either side", (tester) async { 130 | const leaderSize = Size(100, 100); 131 | 132 | await Gallery( 133 | "Preferred Position Aligner - Not Enough Space on Either Side", 134 | fileName: "follower_preferred-position-aligner_not-enough-space-on-either-size", 135 | layout: ftlGridGoldenSceneLayout, 136 | itemConstraints: const BoxConstraints.tightFor(width: 150, height: 150), 137 | itemSetup: (tester) async => tester.pump(), 138 | ) 139 | .itemFromWidget( 140 | description: "Left (Constrained)", 141 | widget: _buildLeaderAndFollower(_left, leaderSize: leaderSize), 142 | ) 143 | .itemFromWidget( 144 | description: "Top (Constrained)", 145 | // Slight mismatch due to partial pixel positioning at center. 146 | tolerancePx: 250, 147 | widget: _buildLeaderAndFollower(_top, leaderSize: leaderSize), 148 | ) 149 | .itemFromWidget( 150 | description: "Right (Constrained)", 151 | // Slight mismatch due to partial pixel positioning at center. 152 | tolerancePx: 250, 153 | widget: _buildLeaderAndFollower(_right, leaderSize: leaderSize), 154 | ) 155 | .itemFromWidget( 156 | description: "Bottom (Constrained)", 157 | widget: _buildLeaderAndFollower(_bottom, leaderSize: leaderSize), 158 | ) 159 | .run(tester); 160 | }); 161 | }); 162 | } 163 | 164 | Widget _buildLeaderAndFollower( 165 | _PreferredPosition position, { 166 | Size leaderSize = const Size(10, 10), 167 | Alignment leaderAlignment = Alignment.center, 168 | Size followerSize = const Size(20, 20), 169 | }) { 170 | return buildLeaderAndFollowerWithAligner( 171 | leaderSize: leaderSize, 172 | leaderAlignment: leaderAlignment, 173 | followerSize: followerSize, 174 | aligner: PreferredPositionAligner( 175 | followerPosition: position.side, 176 | leaderCrossAxisAnchor: position.leaderCrossAxisAnchor, 177 | followerCrossAxisAnchor: position.followerCrossAxisAnchor, 178 | ), 179 | ); 180 | } 181 | 182 | const _left = _PreferredPosition( 183 | side: PreferredPrimaryPosition.left, 184 | leaderCrossAxisAnchor: Alignment.centerLeft, 185 | followerCrossAxisAnchor: Alignment.centerRight, 186 | ); 187 | 188 | const _top = _PreferredPosition( 189 | side: PreferredPrimaryPosition.top, 190 | leaderCrossAxisAnchor: Alignment.topCenter, 191 | followerCrossAxisAnchor: Alignment.bottomCenter, 192 | ); 193 | 194 | const _right = _PreferredPosition( 195 | side: PreferredPrimaryPosition.right, 196 | leaderCrossAxisAnchor: Alignment.centerRight, 197 | followerCrossAxisAnchor: Alignment.centerLeft, 198 | ); 199 | 200 | const _bottom = _PreferredPosition( 201 | side: PreferredPrimaryPosition.bottom, 202 | leaderCrossAxisAnchor: Alignment.bottomCenter, 203 | followerCrossAxisAnchor: Alignment.topCenter, 204 | ); 205 | 206 | class _PreferredPosition { 207 | const _PreferredPosition({ 208 | required this.side, 209 | required this.leaderCrossAxisAnchor, 210 | required this.followerCrossAxisAnchor, 211 | }); 212 | 213 | final PreferredPrimaryPosition side; 214 | final Alignment leaderCrossAxisAnchor; 215 | final Alignment followerCrossAxisAnchor; 216 | } 217 | -------------------------------------------------------------------------------- /example/lib/infrastructure/ball_sandbox.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/scheduler.dart'; 3 | import 'package:follow_the_leader/follow_the_leader.dart'; 4 | 5 | /// A [BallSandbox], with a ball that bounces around the screen 6 | /// with a given [follower]. 7 | class BouncingBallSandbox extends StatefulWidget { 8 | const BouncingBallSandbox({ 9 | super.key, 10 | required this.boundsKey, 11 | required this.leaderKey, 12 | required this.followerKey, 13 | required this.followerAligner, 14 | required this.follower, 15 | this.initialBallOffset = Offset.zero, 16 | this.onBallMove, 17 | }); 18 | 19 | final GlobalKey boundsKey; 20 | final GlobalKey leaderKey; 21 | final GlobalKey followerKey; 22 | final FollowerAligner followerAligner; 23 | final Widget follower; 24 | final Offset initialBallOffset; 25 | final void Function(Offset)? onBallMove; 26 | 27 | @override 28 | State createState() => _BouncingBallSandboxState(); 29 | } 30 | 31 | class _BouncingBallSandboxState extends State with SingleTickerProviderStateMixin { 32 | /// Initial velocity of the leader. 33 | final Offset _initialVelocity = const Offset(300, 300); 34 | 35 | /// Current velocity of the leader. 36 | /// 37 | /// The velocity is updated whenever the leader hits an edge of the screen. 38 | late Offset _velocity; 39 | 40 | /// Last [Duration] given by the ticker. 41 | Duration? _lastElapsed; 42 | 43 | /// Current offset of the leader. 44 | /// 45 | /// The offset changes at every tick. 46 | late Offset _ballOffset; 47 | 48 | late Ticker ticker; 49 | 50 | @override 51 | void initState() { 52 | super.initState(); 53 | ticker = createTicker(_onTick)..start(); 54 | _velocity = _initialVelocity; 55 | _ballOffset = widget.initialBallOffset; 56 | } 57 | 58 | @override 59 | void dispose() { 60 | ticker.dispose(); 61 | super.dispose(); 62 | } 63 | 64 | void _onTick(Duration elapsed) { 65 | if (_lastElapsed == null) { 66 | _lastElapsed = elapsed; 67 | return; 68 | } 69 | 70 | final dt = elapsed.inMilliseconds - _lastElapsed!.inMilliseconds; 71 | _lastElapsed = elapsed; 72 | 73 | final bounds = (widget.boundsKey.currentContext?.findRenderObject() as RenderBox?)?.size ?? Size.zero; 74 | 75 | // Offset where the leader hits the right edge. 76 | final maximumLeaderHorizontalOffset = bounds.width - _ballRadius * 2; 77 | 78 | // Offset where the leader hits the bottom edge. 79 | final maximumLeaderVerticalOffset = bounds.height - _ballRadius * 2; 80 | 81 | // Travelled distance between the last tick and the current. 82 | final distance = _velocity * (dt / 1000.0); 83 | 84 | Offset newOffset = _ballOffset + distance; 85 | 86 | // Check for hits. 87 | 88 | if (newOffset.dx > maximumLeaderHorizontalOffset) { 89 | // The ball hit the right edge. 90 | _velocity = Offset(-_velocity.dx, _velocity.dy); 91 | newOffset = Offset(maximumLeaderHorizontalOffset, newOffset.dy); 92 | } 93 | 94 | if (newOffset.dx <= 0) { 95 | // The ball hit the left edge. 96 | _velocity = Offset(-_velocity.dx, _velocity.dy); 97 | newOffset = Offset(0, newOffset.dy); 98 | } 99 | 100 | if (newOffset.dy > maximumLeaderVerticalOffset) { 101 | // The ball hit the bottom. 102 | _velocity = Offset(_velocity.dx, -_velocity.dy); 103 | newOffset = Offset(newOffset.dx, maximumLeaderVerticalOffset); 104 | } 105 | 106 | if (newOffset.dy <= 0) { 107 | // The ball hit the top. 108 | _velocity = Offset(_velocity.dx, -_velocity.dy); 109 | newOffset = Offset(newOffset.dx, 0); 110 | } 111 | 112 | setState(() { 113 | // Update the ball offset before updating the menu focal point. 114 | _ballOffset = newOffset; 115 | widget.onBallMove?.call(_ballOffset); 116 | }); 117 | } 118 | 119 | @override 120 | Widget build(BuildContext context) { 121 | return BallSandbox( 122 | boundsKey: widget.boundsKey, 123 | leaderKey: widget.leaderKey, 124 | followerKey: widget.followerKey, 125 | ballOffset: _ballOffset, 126 | followerAligner: widget.followerAligner, 127 | follower: widget.follower, 128 | ); 129 | } 130 | } 131 | 132 | /// A [BallSandbox], which lets the user drag the ball around the 133 | /// screen with a given [follower]. 134 | class DraggableBallSandbox extends StatefulWidget { 135 | const DraggableBallSandbox({ 136 | super.key, 137 | required this.boundsKey, 138 | required this.leaderKey, 139 | required this.followerKey, 140 | required this.followerAligner, 141 | required this.follower, 142 | this.initialBallOffset = Offset.zero, 143 | this.onBallMove, 144 | }); 145 | 146 | final GlobalKey boundsKey; 147 | final GlobalKey leaderKey; 148 | final GlobalKey followerKey; 149 | final FollowerAligner followerAligner; 150 | final Widget follower; 151 | final Offset initialBallOffset; 152 | final void Function(Offset)? onBallMove; 153 | 154 | @override 155 | State createState() => _DraggableBallSandboxState(); 156 | } 157 | 158 | class _DraggableBallSandboxState extends State { 159 | /// The (x,y) position of the draggable object, which is also our `Leader`. 160 | late Offset _ballOffset; 161 | 162 | @override 163 | void initState() { 164 | super.initState(); 165 | _ballOffset = widget.initialBallOffset; 166 | } 167 | 168 | void _onPanUpdate(DragUpdateDetails details) { 169 | setState(() { 170 | // Update _draggableOffset before updating the menu focal point 171 | _ballOffset += details.delta; 172 | widget.onBallMove?.call(_ballOffset); 173 | }); 174 | } 175 | 176 | @override 177 | Widget build(BuildContext context) { 178 | return BallSandbox( 179 | boundsKey: widget.boundsKey, 180 | leaderKey: widget.leaderKey, 181 | followerKey: widget.followerKey, 182 | ballOffset: _ballOffset, 183 | ballDecorator: (ball) { 184 | return GestureDetector( 185 | onPanUpdate: _onPanUpdate, 186 | child: ball, 187 | ); 188 | }, 189 | followerAligner: widget.followerAligner, 190 | follower: widget.follower, 191 | ); 192 | } 193 | } 194 | 195 | /// Displays a ball with an associated follower. 196 | /// 197 | /// The ball can be given any offset, and the ball can be decorated 198 | /// with another widget, such as a `GestureDetector`. 199 | class BallSandbox extends StatefulWidget { 200 | const BallSandbox({ 201 | super.key, 202 | required this.boundsKey, 203 | required this.leaderKey, 204 | required this.followerKey, 205 | required this.ballOffset, 206 | this.ballDecorator, 207 | required this.followerAligner, 208 | required this.follower, 209 | }); 210 | 211 | final GlobalKey boundsKey; 212 | final GlobalKey leaderKey; 213 | final GlobalKey followerKey; 214 | final Offset ballOffset; 215 | final Widget Function(Widget ball)? ballDecorator; 216 | final FollowerAligner followerAligner; 217 | final Widget follower; 218 | 219 | @override 220 | State createState() => _BallSandboxState(); 221 | } 222 | 223 | class _BallSandboxState extends State { 224 | /// Links the [Leader] and the [Follower]. 225 | late LeaderLink _leaderLink; 226 | 227 | @override 228 | void initState() { 229 | super.initState(); 230 | _leaderLink = LeaderLink(); 231 | } 232 | 233 | @override 234 | Widget build(BuildContext context) { 235 | return Stack( 236 | key: widget.boundsKey, 237 | children: [ 238 | _buildLeader(), 239 | _buildFollower(), 240 | ], 241 | ); 242 | } 243 | 244 | Widget _buildLeader() { 245 | return Positioned( 246 | left: widget.ballOffset.dx, 247 | top: widget.ballOffset.dy, 248 | child: Leader( 249 | key: widget.leaderKey, 250 | link: _leaderLink, 251 | child: widget.ballDecorator != null // 252 | ? widget.ballDecorator!.call(_buildBall()) // 253 | : _buildBall(), 254 | ), 255 | ); 256 | } 257 | 258 | Widget _buildBall() { 259 | return Container( 260 | height: _ballRadius * 2, 261 | width: _ballRadius * 2, 262 | decoration: const BoxDecoration( 263 | shape: BoxShape.circle, 264 | color: Colors.black, 265 | ), 266 | ); 267 | } 268 | 269 | Widget _buildFollower() { 270 | return Positioned( 271 | left: 0, 272 | top: 0, 273 | child: Follower.withAligner( 274 | key: widget.followerKey, 275 | link: _leaderLink, 276 | aligner: widget.followerAligner, 277 | boundary: WidgetFollowerBoundary(boundaryKey: widget.boundsKey), 278 | child: widget.follower, 279 | ), 280 | ); 281 | } 282 | } 283 | 284 | const double _ballRadius = 50.0; 285 | -------------------------------------------------------------------------------- /example/pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.13.0" 12 | boolean_selector: 13 | dependency: transitive 14 | description: 15 | name: boolean_selector 16 | sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.1.2" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.4.0" 28 | clock: 29 | dependency: transitive 30 | description: 31 | name: clock 32 | sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.1.2" 36 | collection: 37 | dependency: transitive 38 | description: 39 | name: collection 40 | sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.19.1" 44 | fake_async: 45 | dependency: transitive 46 | description: 47 | name: fake_async 48 | sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "1.3.3" 52 | flutter: 53 | dependency: "direct main" 54 | description: flutter 55 | source: sdk 56 | version: "0.0.0" 57 | flutter_lints: 58 | dependency: "direct dev" 59 | description: 60 | name: flutter_lints 61 | sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c 62 | url: "https://pub.dev" 63 | source: hosted 64 | version: "2.0.1" 65 | flutter_plugin_android_lifecycle: 66 | dependency: transitive 67 | description: 68 | name: flutter_plugin_android_lifecycle 69 | sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e 70 | url: "https://pub.dev" 71 | source: hosted 72 | version: "2.0.28" 73 | flutter_test: 74 | dependency: "direct dev" 75 | description: flutter 76 | source: sdk 77 | version: "0.0.0" 78 | flutter_test_runners: 79 | dependency: transitive 80 | description: 81 | name: flutter_test_runners 82 | sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d 83 | url: "https://pub.dev" 84 | source: hosted 85 | version: "0.0.4" 86 | follow_the_leader: 87 | dependency: "direct main" 88 | description: 89 | path: ".." 90 | relative: true 91 | source: path 92 | version: "0.0.4+8" 93 | golden_toolkit: 94 | dependency: "direct dev" 95 | description: 96 | name: golden_toolkit 97 | sha256: "111e913c99632d470fed8263900d64fd75ec1ca2997702e0b2c05f63649d5440" 98 | url: "https://pub.dev" 99 | source: hosted 100 | version: "0.11.0" 101 | leak_tracker: 102 | dependency: transitive 103 | description: 104 | name: leak_tracker 105 | sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" 106 | url: "https://pub.dev" 107 | source: hosted 108 | version: "10.0.9" 109 | leak_tracker_flutter_testing: 110 | dependency: transitive 111 | description: 112 | name: leak_tracker_flutter_testing 113 | sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 114 | url: "https://pub.dev" 115 | source: hosted 116 | version: "3.0.9" 117 | leak_tracker_testing: 118 | dependency: transitive 119 | description: 120 | name: leak_tracker_testing 121 | sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" 122 | url: "https://pub.dev" 123 | source: hosted 124 | version: "3.0.1" 125 | lints: 126 | dependency: transitive 127 | description: 128 | name: lints 129 | sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" 130 | url: "https://pub.dev" 131 | source: hosted 132 | version: "2.0.1" 133 | logging: 134 | dependency: "direct main" 135 | description: 136 | name: logging 137 | sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 138 | url: "https://pub.dev" 139 | source: hosted 140 | version: "1.3.0" 141 | matcher: 142 | dependency: transitive 143 | description: 144 | name: matcher 145 | sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 146 | url: "https://pub.dev" 147 | source: hosted 148 | version: "0.12.17" 149 | material_color_utilities: 150 | dependency: transitive 151 | description: 152 | name: material_color_utilities 153 | sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 154 | url: "https://pub.dev" 155 | source: hosted 156 | version: "0.11.1" 157 | meta: 158 | dependency: transitive 159 | description: 160 | name: meta 161 | sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c 162 | url: "https://pub.dev" 163 | source: hosted 164 | version: "1.16.0" 165 | overlord: 166 | dependency: "direct main" 167 | description: 168 | path: "." 169 | ref: ebf5d372beba278122525fb957902dec9a80b306 170 | resolved-ref: ebf5d372beba278122525fb957902dec9a80b306 171 | url: "https://github.com/Flutter-Bounty-Hunters/overlord" 172 | source: git 173 | version: "0.0.3+5" 174 | page_list_viewport: 175 | dependency: "direct main" 176 | description: 177 | path: "." 178 | ref: HEAD 179 | resolved-ref: "76871bf3d4b058a6ecd8aa090b5600dd201da6f3" 180 | url: "https://github.com/Flutter-Bounty-Hunters/page_list_viewport" 181 | source: git 182 | version: "0.0.1" 183 | path: 184 | dependency: transitive 185 | description: 186 | name: path 187 | sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 188 | url: "https://pub.dev" 189 | source: hosted 190 | version: "1.9.1" 191 | plugin_platform_interface: 192 | dependency: transitive 193 | description: 194 | name: plugin_platform_interface 195 | sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 196 | url: "https://pub.dev" 197 | source: hosted 198 | version: "2.1.8" 199 | sky_engine: 200 | dependency: transitive 201 | description: flutter 202 | source: sdk 203 | version: "0.0.0" 204 | source_span: 205 | dependency: transitive 206 | description: 207 | name: source_span 208 | sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" 209 | url: "https://pub.dev" 210 | source: hosted 211 | version: "1.10.1" 212 | stack_trace: 213 | dependency: transitive 214 | description: 215 | name: stack_trace 216 | sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" 217 | url: "https://pub.dev" 218 | source: hosted 219 | version: "1.12.1" 220 | stream_channel: 221 | dependency: transitive 222 | description: 223 | name: stream_channel 224 | sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" 225 | url: "https://pub.dev" 226 | source: hosted 227 | version: "2.1.4" 228 | string_scanner: 229 | dependency: transitive 230 | description: 231 | name: string_scanner 232 | sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" 233 | url: "https://pub.dev" 234 | source: hosted 235 | version: "1.4.1" 236 | super_keyboard: 237 | dependency: transitive 238 | description: 239 | name: super_keyboard 240 | sha256: "1a0a32dd799118554fb801822ae2eed7669fbbf7c754d94d240ec3e8ee7dbd67" 241 | url: "https://pub.dev" 242 | source: hosted 243 | version: "0.2.2" 244 | term_glyph: 245 | dependency: transitive 246 | description: 247 | name: term_glyph 248 | sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" 249 | url: "https://pub.dev" 250 | source: hosted 251 | version: "1.2.2" 252 | test_api: 253 | dependency: transitive 254 | description: 255 | name: test_api 256 | sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd 257 | url: "https://pub.dev" 258 | source: hosted 259 | version: "0.7.4" 260 | vector_math: 261 | dependency: transitive 262 | description: 263 | name: vector_math 264 | sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 265 | url: "https://pub.dev" 266 | source: hosted 267 | version: "2.1.4" 268 | vm_service: 269 | dependency: transitive 270 | description: 271 | name: vm_service 272 | sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 273 | url: "https://pub.dev" 274 | source: hosted 275 | version: "15.0.0" 276 | sdks: 277 | dart: ">=3.7.0-0 <4.0.0" 278 | flutter: ">=3.27.0" 279 | -------------------------------------------------------------------------------- /example/lib/demo_scrollables.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:follow_the_leader/follow_the_leader.dart'; 3 | import 'package:overlord/follow_the_leader.dart'; 4 | import 'package:overlord/overlord.dart'; 5 | 6 | /// Demonstrates that leaders and followers appear at expected locations, 7 | /// even when they sit within a scrolling viewport. 8 | class ScrollablesDemo extends StatefulWidget { 9 | const ScrollablesDemo({Key? key}) : super(key: key); 10 | 11 | @override 12 | State createState() => _ScrollablesDemoState(); 13 | } 14 | 15 | class _ScrollablesDemoState extends State { 16 | @override 17 | Widget build(BuildContext context) { 18 | return Container( 19 | width: double.infinity, 20 | height: double.infinity, 21 | color: const Color(0xFF222222), 22 | child: const SafeArea( 23 | child: Row( 24 | children: [ 25 | Expanded(child: _VerticalList()), 26 | Expanded( 27 | child: Column( 28 | children: [ 29 | Expanded(child: _HorizontalList()), 30 | Spacer(), 31 | ], 32 | ), 33 | ) 34 | ], 35 | ), 36 | ), 37 | ); 38 | } 39 | } 40 | 41 | class _VerticalList extends StatefulWidget { 42 | const _VerticalList({Key? key}) : super(key: key); 43 | 44 | @override 45 | State<_VerticalList> createState() => _VerticalListState(); 46 | } 47 | 48 | class _VerticalListState extends State<_VerticalList> { 49 | final _boundsKey = GlobalKey(); 50 | late final ScrollController _scrollController; 51 | 52 | @override 53 | void initState() { 54 | super.initState(); 55 | _scrollController = ScrollController(); 56 | } 57 | 58 | @override 59 | void dispose() { 60 | _scrollController.dispose(); 61 | super.dispose(); 62 | } 63 | 64 | @override 65 | Widget build(BuildContext context) { 66 | return Stack( 67 | children: [ 68 | Positioned.fill( 69 | child: LayoutBuilder(builder: (context, constraints) { 70 | return ListView.builder( 71 | controller: _scrollController, 72 | itemBuilder: (context, index) { 73 | if (index == 1) { 74 | return _LeaderAndFollowerListItem( 75 | height: constraints.maxHeight, 76 | recalculateGlobalOffset: _scrollController, 77 | boundsKey: _boundsKey, 78 | ); 79 | } 80 | 81 | return _EmptyListItem(height: constraints.maxHeight); 82 | }, 83 | ); 84 | }), 85 | ), 86 | Positioned.fill( 87 | child: IgnorePointer( 88 | child: Padding( 89 | padding: const EdgeInsets.all(100), 90 | child: DecoratedBox( 91 | key: _boundsKey, 92 | decoration: BoxDecoration( 93 | color: Colors.green.withValues(alpha: 0.10), 94 | ), 95 | ), 96 | ), 97 | ), 98 | ), 99 | ], 100 | ); 101 | } 102 | } 103 | 104 | class _HorizontalList extends StatefulWidget { 105 | const _HorizontalList({Key? key}) : super(key: key); 106 | 107 | @override 108 | State<_HorizontalList> createState() => _HorizontalListState(); 109 | } 110 | 111 | class _HorizontalListState extends State<_HorizontalList> { 112 | final _boundsKey = GlobalKey(); 113 | late final ScrollController _scrollController; 114 | 115 | @override 116 | void initState() { 117 | super.initState(); 118 | _scrollController = ScrollController(); 119 | } 120 | 121 | @override 122 | void dispose() { 123 | _scrollController.dispose(); 124 | super.dispose(); 125 | } 126 | 127 | @override 128 | Widget build(BuildContext context) { 129 | return Stack( 130 | children: [ 131 | Positioned.fill( 132 | child: LayoutBuilder(builder: (context, constraints) { 133 | return ListView.builder( 134 | controller: _scrollController, 135 | scrollDirection: Axis.horizontal, 136 | itemBuilder: (context, index) { 137 | if (index == 1) { 138 | return _LeaderAndFollowerListItem( 139 | width: constraints.maxWidth, 140 | recalculateGlobalOffset: _scrollController, 141 | boundsKey: _boundsKey, 142 | ); 143 | } 144 | 145 | return _EmptyListItem( 146 | width: constraints.maxWidth, 147 | ); 148 | }, 149 | ); 150 | }), 151 | ), 152 | Positioned.fill( 153 | child: IgnorePointer( 154 | child: Padding( 155 | padding: const EdgeInsets.all(100), 156 | child: DecoratedBox( 157 | key: _boundsKey, 158 | decoration: BoxDecoration( 159 | color: Colors.green.withValues(alpha: 0.10), 160 | ), 161 | ), 162 | ), 163 | ), 164 | ), 165 | ], 166 | ); 167 | } 168 | } 169 | 170 | class _EmptyListItem extends StatelessWidget { 171 | const _EmptyListItem({ 172 | Key? key, 173 | this.width, 174 | this.height, 175 | }) : super(key: key); 176 | 177 | final double? width; 178 | final double? height; 179 | 180 | @override 181 | Widget build(BuildContext context) { 182 | return SizedBox( 183 | width: width ?? double.infinity, 184 | height: height ?? double.infinity, 185 | child: DecoratedBox( 186 | decoration: BoxDecoration( 187 | color: Colors.black.withValues(alpha: 0.05), 188 | border: Border.all(color: Colors.black, width: 1), 189 | ), 190 | ), 191 | ); 192 | } 193 | } 194 | 195 | class _LeaderAndFollowerListItem extends StatefulWidget { 196 | const _LeaderAndFollowerListItem({ 197 | Key? key, 198 | this.width, 199 | this.height, 200 | required this.recalculateGlobalOffset, 201 | required this.boundsKey, 202 | }) : super(key: key); 203 | 204 | final double? width; 205 | final double? height; 206 | final Listenable recalculateGlobalOffset; 207 | final GlobalKey boundsKey; 208 | 209 | @override 210 | State<_LeaderAndFollowerListItem> createState() => _LeaderAndFollowerListItemState(); 211 | } 212 | 213 | class _LeaderAndFollowerListItemState extends State<_LeaderAndFollowerListItem> { 214 | final _anchor = LeaderLink(); 215 | 216 | late final FollowerBoundary? _viewportBoundary; 217 | late final FollowerAligner _aligner; 218 | late final _focalPoint = LeaderMenuFocalPoint(link: _anchor); 219 | 220 | @override 221 | void initState() { 222 | super.initState(); 223 | 224 | _aligner = CupertinoPopoverToolbarAligner(); 225 | } 226 | 227 | @override 228 | void didChangeDependencies() { 229 | super.didChangeDependencies(); 230 | 231 | _viewportBoundary = WidgetFollowerBoundary(boundaryKey: widget.boundsKey); 232 | } 233 | 234 | @override 235 | void didUpdateWidget(_LeaderAndFollowerListItem oldWidget) { 236 | super.didUpdateWidget(oldWidget); 237 | 238 | if (widget.boundsKey != oldWidget.boundsKey) { 239 | _viewportBoundary = WidgetFollowerBoundary(boundaryKey: widget.boundsKey); 240 | _aligner = CupertinoPopoverToolbarAligner(); 241 | } 242 | } 243 | 244 | @override 245 | Widget build(BuildContext context) { 246 | return SizedBox( 247 | width: widget.width, 248 | height: widget.height, 249 | child: BuildInOrder( 250 | children: [ 251 | DecoratedBox( 252 | decoration: BoxDecoration( 253 | border: Border.all(color: Colors.black, width: 1), 254 | ), 255 | child: Center( 256 | child: Leader( 257 | link: _anchor, 258 | recalculateGlobalOffset: widget.recalculateGlobalOffset, 259 | child: Container(width: 25, height: 25, color: Colors.red), 260 | ), 261 | ), 262 | ), 263 | SizedBox.expand( 264 | child: DecoratedBox( 265 | decoration: BoxDecoration(border: Border.all(width: 1, color: Colors.red)), 266 | child: FollowerFadeOutBeyondBoundary( 267 | boundary: _viewportBoundary, 268 | link: _anchor, 269 | child: Follower.withAligner( 270 | link: _anchor, 271 | aligner: _aligner, 272 | boundary: _viewportBoundary, 273 | repaintWhenLeaderChanges: true, 274 | child: CupertinoPopoverToolbar( 275 | focalPoint: _focalPoint, 276 | children: [ 277 | TextButton( 278 | // ignore: avoid_print 279 | onPressed: () => print("one"), 280 | child: const Text("One", style: TextStyle(color: Colors.white)), 281 | ), 282 | TextButton( 283 | // ignore: avoid_print 284 | onPressed: () => print("two"), 285 | child: const Text("Two", style: TextStyle(color: Colors.white)), 286 | ), 287 | ], 288 | ), 289 | ), 290 | ), 291 | ), 292 | ), 293 | ], 294 | ), 295 | ); 296 | } 297 | } 298 | --------------------------------------------------------------------------------