├── 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 |
4 |
5 |
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 |
3 |
4 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------