diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..cf744906 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.22.3" +} \ No newline at end of file diff --git a/.github/actions/setup_flutter/action.yaml b/.github/actions/setup_flutter/action.yaml index a57cbfd1..3f44645c 100644 --- a/.github/actions/setup_flutter/action.yaml +++ b/.github/actions/setup_flutter/action.yaml @@ -6,6 +6,10 @@ inputs: description: "The working directory where the Flutter project is located." required: false default: "." + version: + description: "The version of Flutter to install." + required: false + default: "3.x" runs: using: "composite" @@ -14,7 +18,7 @@ runs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.x + flutter-version: ${{ inputs.version }} cache: true - name: Flutter version diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f5f3dfd3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Related issues (optional) + +None. + +## Description + +Blah blah blah. + +## Summary (check all that apply) + +- [ ] Modified / added code +- [ ] Modified / added tests +- [ ] Modified / added examples +- [ ] Modified / added others (pubspec.yaml, workflows, etc...) +- [ ] Updated README +- [ ] Contains breaking changes + - [ ] Created / updated migration guide +- [ ] Incremented version number + - [ ] Updated CHANGELOG diff --git a/.github/workflows/code_check.yaml b/.github/workflows/code_check.yaml index eec32757..a49c430b 100644 --- a/.github/workflows/code_check.yaml +++ b/.github/workflows/code_check.yaml @@ -9,30 +9,52 @@ env: PATTERN_CHECKER: ${{github.workspace}}/scripts/pattern_checker.sh jobs: - # Change detection - changes: + setup: runs-on: ubuntu-latest permissions: pull-requests: read outputs: - dart-files: ${{ steps.filter.outputs.dart-files }} + flutter-file-changed: ${{ steps.filter.outputs.flutter-file-changed }} + flutter-lower-bound: ${{ steps.flutter-version-constraint.outputs.lower-bound }} + flutter-upper-bound: ${{ steps.flutter-version-constraint.outputs.upper-bound }} steps: - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + + - name: Filter changed files + uses: dorny/paths-filter@v3 id: filter with: filters: | - dart-files: + flutter-file-changed: - '**.dart' + - 'pubspec.yaml' + - 'pubspec.lock' + + - name: Get Flutter SDK version constraint + id: flutter-version-constraint + # Extract the lower bound from pubspec.yaml and the upper bound from .fvmrc + run: | + sdk_constraint=$(cat pubspec.yaml | yq .environment.flutter) + lower_bound=$(echo "$sdk_constraint" | grep -oP '(?<=\>=)[0-9]+\.[0-9]+\.[0-9]+' | head -1) + upper_bound=$(cat .fvmrc | jq -r .flutter) + echo "lower-bound=$lower_bound" >> "$GITHUB_OUTPUT" + echo "upper-bound=$upper_bound" >> "$GITHUB_OUTPUT" + + - name: Print output values + run: | + echo "flutter-file-changed=${{ steps.filter.outputs.flutter-file-changed }}" + echo "flutter-lower-bound=${{ steps.flutter-version-constraint.outputs.lower-bound }}" + echo "flutter-upper-bound=${{ steps.flutter-version-constraint.outputs.upper-bound }}" - # Static code analysis analysis: - needs: changes - if: ${{ needs.changes.outputs.dart-files == 'true' }} + needs: setup + if: ${{ needs.setup.outputs.flutter-file-changed == 'true' }} runs-on: ubuntu-latest strategy: matrix: - target: [package, cookbook] + flutter-version: + - ${{ needs.setup.outputs.flutter-lower-bound }} + - ${{ needs.setup.outputs.flutter-upper-bound }} steps: - name: Checkout uses: actions/checkout@v4 @@ -40,29 +62,30 @@ jobs: - name: Setup Flutter uses: ./.github/actions/setup_flutter with: - target: ${{ matrix.target }} + version: ${{ matrix.flutter-version }} - name: Format run: dart format . -o none --set-exit-if-changed - working-directory: ${{ matrix.target }} - name: Analyze run: dart analyze - working-directory: ${{ matrix.target }} - name: Disallowed patterns check run: bash ${{ env.PATTERN_CHECKER }} "*.dart" "--" "debugPrint" - working-directory: ${{ matrix.target }} - # Unit testing testing: - needs: changes - if: ${{ needs.changes.outputs.dart-files == 'true' }} + needs: setup + if: ${{ needs.setup.outputs.flutter-file-changed == 'true' }} runs-on: ubuntu-latest permissions: contents: read actions: read checks: write + strategy: + matrix: + flutter-version: + - ${{ needs.setup.outputs.flutter-lower-bound }} + - ${{ needs.setup.outputs.flutter-upper-bound }} steps: - name: Checkout uses: actions/checkout@v4 @@ -70,17 +93,17 @@ jobs: - name: Setup Flutter uses: ./.github/actions/setup_flutter with: - target: package + version: ${{ matrix.flutter-version }} - name: Run unit tests run: flutter test --file-reporter="json:${{ env.FLUTTER_TEST_REPORT }}" - working-directory: package - name: Write test report uses: dorny/test-reporter@v1 - if: success() || failure() + # PRs from forks have no write permissions. + if: github.event.pull_request.head.repo.fork == false && (success() || failure()) with: - name: Test Report + name: Test Report (with Flutter ${{ matrix.flutter-version }}) path: ${{ env.FLUTTER_TEST_REPORT }} reporter: flutter-json diff --git a/.github/workflows/dry_publish.yaml b/.github/workflows/dry_publish.yaml index 1a391bcd..71eb3658 100644 --- a/.github/workflows/dry_publish.yaml +++ b/.github/workflows/dry_publish.yaml @@ -14,8 +14,6 @@ jobs: - name: Setup Flutter uses: ./.github/actions/setup_flutter - with: - target: package - name: Install pana run: dart pub global activate pana @@ -23,7 +21,7 @@ jobs: - name: Run pana id: analysis run: | - pana --json package 2>&1 | grep -v '^{"logName"' > pana-report.json + pana --json . 2>&1 | grep -v '^{"logName"' > pana-report.json JSON_OUTPUT=$(cat pana-report.json | jq -c .) echo "::set-output name=json_output::$JSON_OUTPUT" @@ -32,9 +30,8 @@ jobs: report: ${{ steps.analysis.outputs.json_output }} min-convention-points: 30 min-platform-points: 20 - min-analysis-points: 50 + min-analysis-points: 40 min-dependency-points: 20 supported-platforms: ios, android - run: flutter pub publish --dry-run - working-directory: package diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b6538843..8c20eabc 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -14,12 +14,9 @@ jobs: - name: Setup Flutter uses: ./.github/actions/setup_flutter - with: - target: package - name: Setup OIDC token uses: dart-lang/setup-dart@v1 - name: Publish to pub.dev run: flutter pub publish --force - working-directory: package diff --git a/.gitignore b/.gitignore index 275fbb53..0ed744a4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,79 @@ .history .svn/ migrate_working_dir/ -pubspec.lock +# Flutter/Dart/Pub related +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +# FVM Version Cache +.fvm/ + +# IntelliJ related +# Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Caches +.idea/caches/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 00000000..6bbe2aee --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..4bec4ea8 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,117 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..a55e7a17 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..469b00f4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..04db2f06 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 00000000..49481ad4 --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..931b96c3 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/AI_Playlist_Generator.xml b/.idea/runConfigurations/AI_Playlist_Generator.xml new file mode 100644 index 00000000..d66593ca --- /dev/null +++ b/.idea/runConfigurations/AI_Playlist_Generator.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/AirBnb.xml b/.idea/runConfigurations/AirBnb.xml new file mode 100644 index 00000000..b0fc46d3 --- /dev/null +++ b/.idea/runConfigurations/AirBnb.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Bottom_Bar_Visibility.xml b/.idea/runConfigurations/Bottom_Bar_Visibility.xml new file mode 100644 index 00000000..6f47469f --- /dev/null +++ b/.idea/runConfigurations/Bottom_Bar_Visibility.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Cupertino_Modal_Sheet.xml b/.idea/runConfigurations/Cupertino_Modal_Sheet.xml new file mode 100644 index 00000000..868fa853 --- /dev/null +++ b/.idea/runConfigurations/Cupertino_Modal_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Declarative_Modal_Sheet.xml b/.idea/runConfigurations/Declarative_Modal_Sheet.xml new file mode 100644 index 00000000..362e4a07 --- /dev/null +++ b/.idea/runConfigurations/Declarative_Modal_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Declarative_Navigation_Sheet.xml b/.idea/runConfigurations/Declarative_Navigation_Sheet.xml new file mode 100644 index 00000000..3a9449a2 --- /dev/null +++ b/.idea/runConfigurations/Declarative_Navigation_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Draggable_Sheet.xml b/.idea/runConfigurations/Draggable_Sheet.xml new file mode 100644 index 00000000..e06b0fde --- /dev/null +++ b/.idea/runConfigurations/Draggable_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Imperative_Modal_Sheet.xml b/.idea/runConfigurations/Imperative_Modal_Sheet.xml new file mode 100644 index 00000000..7c4c63c6 --- /dev/null +++ b/.idea/runConfigurations/Imperative_Modal_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Imperative_Navigation_Sheet.xml b/.idea/runConfigurations/Imperative_Navigation_Sheet.xml new file mode 100644 index 00000000..f1b68a0b --- /dev/null +++ b/.idea/runConfigurations/Imperative_Navigation_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Keyboard_Dismiss_Behavior.xml b/.idea/runConfigurations/Keyboard_Dismiss_Behavior.xml new file mode 100644 index 00000000..5d09cc48 --- /dev/null +++ b/.idea/runConfigurations/Keyboard_Dismiss_Behavior.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Navigation_Sheet_And_Keyboard.xml b/.idea/runConfigurations/Navigation_Sheet_And_Keyboard.xml new file mode 100644 index 00000000..516df851 --- /dev/null +++ b/.idea/runConfigurations/Navigation_Sheet_And_Keyboard.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Safari.xml b/.idea/runConfigurations/Safari.xml new file mode 100644 index 00000000..72f737e8 --- /dev/null +++ b/.idea/runConfigurations/Safari.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/ScrollableSheet_And_PageView.xml b/.idea/runConfigurations/ScrollableSheet_And_PageView.xml new file mode 100644 index 00000000..ddb51e96 --- /dev/null +++ b/.idea/runConfigurations/ScrollableSheet_And_PageView.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Scrollable_Sheet.xml b/.idea/runConfigurations/Scrollable_Sheet.xml new file mode 100644 index 00000000..8ff54dd0 --- /dev/null +++ b/.idea/runConfigurations/Scrollable_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Sheet_Content_Scaffold.xml b/.idea/runConfigurations/Sheet_Content_Scaffold.xml new file mode 100644 index 00000000..0cef1efa --- /dev/null +++ b/.idea/runConfigurations/Sheet_Content_Scaffold.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Sheet_Controller.xml b/.idea/runConfigurations/Sheet_Controller.xml new file mode 100644 index 00000000..ec367dea --- /dev/null +++ b/.idea/runConfigurations/Sheet_Controller.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Sheet_Draggable.xml b/.idea/runConfigurations/Sheet_Draggable.xml new file mode 100644 index 00000000..689adf6b --- /dev/null +++ b/.idea/runConfigurations/Sheet_Draggable.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Sheet_Physics.xml b/.idea/runConfigurations/Sheet_Physics.xml new file mode 100644 index 00000000..528edd57 --- /dev/null +++ b/.idea/runConfigurations/Sheet_Physics.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Sheet_Position_Driven_Animation.xml b/.idea/runConfigurations/Sheet_Position_Driven_Animation.xml new file mode 100644 index 00000000..ff8ef49c --- /dev/null +++ b/.idea/runConfigurations/Sheet_Position_Driven_Animation.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/TextField_With_Multiple_Stops.xml b/.idea/runConfigurations/TextField_With_Multiple_Stops.xml new file mode 100644 index 00000000..75822d55 --- /dev/null +++ b/.idea/runConfigurations/TextField_With_Multiple_Stops.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Todo_List.xml b/.idea/runConfigurations/Todo_List.xml new file mode 100644 index 00000000..40962ca3 --- /dev/null +++ b/.idea/runConfigurations/Todo_List.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/iOS_Style_Modal_Navigation_Sheet.xml b/.idea/runConfigurations/iOS_Style_Modal_Navigation_Sheet.xml new file mode 100644 index 00000000..8e489c0f --- /dev/null +++ b/.idea/runConfigurations/iOS_Style_Modal_Navigation_Sheet.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/smooth_sheets.iml b/.idea/smooth_sheets.iml new file mode 100644 index 00000000..3144690d --- /dev/null +++ b/.idea/smooth_sheets.iml @@ -0,0 +1,500 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 19ecfa80..853918bf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,126 +9,154 @@ "request": "launch", "type": "dart", "program": "lib/showcase/ai_playlist_generator.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Airbnb Mobile App", "request": "launch", "type": "dart", "program": "lib/showcase/airbnb_mobile_app.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Todo List", "request": "launch", "type": "dart", "program": "lib/showcase/todo_list/main.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Safari", "request": "launch", "type": "dart", "program": "lib/showcase/safari/main.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Scrollable Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/scrollable_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Draggable Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/draggable_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" + }, + { + "name": "iOS-Style Modal Navigation Sheet (with go_router)", + "request": "launch", + "type": "dart", + "program": "lib/tutorial/ios_style_declarative_modal_navigation_sheet.dart", + "cwd": "./example" }, { "name": "Declarative Navigation Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/declarative_navigation_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Imperative Navigation Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/imperative_navigation_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Sheet Physics", "request": "launch", "type": "dart", "program": "lib/tutorial/sheet_physics.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Sheet Content Scaffold", "request": "launch", "type": "dart", "program": "lib/tutorial/sheet_content_scaffold.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Bottom Bar Visibility", "request": "launch", "type": "dart", "program": "lib/tutorial/bottom_bar_visibility.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Sheet Controller", "request": "launch", "type": "dart", "program": "lib/tutorial/sheet_controller.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Sheet Draggable", "request": "launch", "type": "dart", "program": "lib/tutorial/sheet_draggable.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Declarative Modal Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/declarative_modal_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Imperative Modal Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/imperative_modal_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Cupertino Modal Sheet", "request": "launch", "type": "dart", "program": "lib/tutorial/cupertino_modal_sheet.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Extent Driven Animation", "request": "launch", "type": "dart", "program": "lib/tutorial/extent_driven_animation.dart", - "cwd": "./cookbook" + "cwd": "./example" }, { "name": "Keyboard Dismiss Behavior", "request": "launch", "type": "dart", "program": "lib/tutorial/keyboard_dismiss_behavior.dart", - "cwd": "./cookbook" + "cwd": "./example" + }, + { + "name": "Navigation Sheet and Keyboard", + "request": "launch", + "type": "dart", + "program": "lib/tutorial/navigation_sheet_and_keyboard.dart", + "cwd": "./example" + }, + { + "name": "ScrollableSheet and PageView", + "request": "launch", + "type": "dart", + "program": "lib/tutorial/scrollable_pageview_sheet.dart", + "cwd": "./example" + }, + { + "name": "TextField with multiple stops", + "request": "launch", + "type": "dart", + "program": "lib/tutorial/textfield_with_multiple_stops.dart", + "cwd": "./example" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..acb7585a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.22.3" +} \ No newline at end of file diff --git a/package/CHANGELOG.md b/CHANGELOG.md similarity index 54% rename from package/CHANGELOG.md rename to CHANGELOG.md index b520d170..7c9358d1 100644 --- a/package/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 0.10.0 Sep 28, 2024 + +**This version contains breaking changes.** See the [migration guide](https://github.com/fujidaiti/smooth_sheets/blob/main/migrations/migration-guide-0.10.x.md) for more details. + +- Fix: Touch is ignored issue not fixed for top edge (#212) +- Fix: Closing keyboard slows down snapping animation (#193) +- Fix: Dynamically changing sheet height doesn't respect snapping constraints (#226) +- Fix: Snapping effect doesn't work when closing keyboard on non-fullscreen sheet (#192) +- Fix: Unwanted bouncing when opening or closing the on-screen keyboard on ScrollableSheet (#245) + +## 0.9.4 Aug 31, 2024 + +- Add `SwipeDismissSensitivity`, a way to customize sensitivity of swipe-to-dismiss action on modal sheet (#222) + +## 0.9.3 Aug 19, 2024 + +- Fix: Press-and-hold gesture in PageView doesn't stop momentum scrolling (#219) + +## 0.9.2 Aug 14, 2024 + +- Fix: Keyboard visibility changes disrupt route transition animation in NavigationSheet (#215) + +## 0.9.1 Jul 30, 2024 + +- Fix: Sometimes touch is ignored when scrollable sheet reaches edge (#209) + +## 0.9.0 Jul 24, 2024 + +This version contains some breaking changes. See the [migration guide](https://github.com/fujidaiti/smooth_sheets/blob/main/docs/migration-guide-0.9.x.md) for more details. + +- Dispatch a notification when drag is cancelled (#204) +- Prefer composition style for SheetKeyboardDismissible (#197) +- Fix: NavigationSheet throws assertion error when starting to scroll in list view during page transition (#199) +- Refactor notification dispatch mechanism (#202) +- Fix: Momentum scrolling continues despite press and hold in list view (#196) +- Refactor: Lift sheet context up (#201) + +## 0.8.2 Jul 11, 2024 + +- Fix: Opening keyboard interrupts sheet animation (#189) + +## 0.8.1 Jun 23, 2024 + +- Fix: Cupertino style modal transition not working with NavigationSheet (#182) + +## 0.8.0 Jun 22, 2024 + +This version contains some breaking changes. See the [migration guide](https://github.com/fujidaiti/smooth_sheets/blob/main/docs/migration-guide-0.8.x.md) for more details. + +- Make stretching behavior of StretchingSheetPhysics more customizable (#171) +- Rename "stretching" to "bouncing" (#173, #177) +- Fix: bouncing physics doesn't respect bounds where sheet can bounce (#178) + +## 0.7.3 Jun 9, 2024 + +- Fix: DropdownButton doesn't work in NavigationSheet (#139) + +## 0.7.2 Jun 9, 2024 + +- Fix: Attaching SheetController to NavigationSheet causes "Null check operator used on a null value" (#151) +- Fix: SheetController attached to NavigationSheet always emits minPixels = 0.0 (#163) + ## 0.7.1 Jun 1, 2024 - Fix: Unwanted bouncing effect when opening keyboard on NavigationSheet (#153) diff --git a/LICENSE b/LICENSE index 0e89d180..9549527b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 Daichi Fujita +Copyright (c) 2024 Daichi Fujita Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile.toml b/Makefile.toml deleted file mode 100644 index 54dd28a1..00000000 --- a/Makefile.toml +++ /dev/null @@ -1,47 +0,0 @@ -[config] -skip_core_tasks = true - -[env] -PROJECT_ROOT = { script = ["pwd"] } -SCRIPTS_DIR = "${PROJECT_ROOT}/scripts" - -[tasks.flutter-build] -script = ''' -flutter clean -flutter pub get -''' - -[tasks.flutter-analyze] -script = ''' -echo "Running dart format" -dart format . -o none --set-exit-if-changed -echo "Running dart analyze" -dart analyze -echo "Running disallowed patterns check" -bash $SCRIPTS_DIR/pattern_checker.sh "*.dart" "--" "debugPrint" -''' - -[tasks.flutter-check] -run_task = { name = ['flutter-build', 'flutter-analyze'] } - -[tasks.build-all] -script_runner = "@duckscript" -script = ''' -echo "Building package" -cd ./package -cm_run_task flutter-build -echo "Building cookbook" -cd ../cookbook -cm_run_task flutter-build -''' - -[tasks.check-all] -script_runner = "@duckscript" -script = ''' -echo "Running flutter-check for package" -cd ./package -cm_run_task flutter-check -echo "Running flutter-check for cookbook" -cd ../cookbook -cm_run_task flutter-check -''' diff --git a/README.md b/README.md deleted file mode 120000 index 89d5ef5f..00000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -package/README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..4fffd1f3 --- /dev/null +++ b/README.md @@ -0,0 +1,376 @@ +# smooth_sheets + +[![GitHub Repo stars](https://img.shields.io/github/stars/fujidaiti/smooth_sheets)](https://github.com/fujidaiti/smooth_sheets) [![GitHub last commit (branch)](https://img.shields.io/github/last-commit/fujidaiti/smooth_sheets/main?logo=git)](https://github.com/fujidaiti/smooth_sheets/commits/main/) [![Pub Version](https://img.shields.io/pub/v/smooth_sheets)](https://pub.dev/packages/smooth_sheets) ![Pub Likes](https://img.shields.io/pub/likes/smooth_sheets) ![Pub Points](https://img.shields.io/pub/points/smooth_sheets) ![Pub Popularity](https://img.shields.io/pub/popularity/smooth_sheets) + +**smooth_sheets** offers modal and persistent sheet widgets for Flutter apps. The key features are: + +- **Smooth motion**: The sheets respond to user interaction with smooth, graceful motion. +- **Highly flexible**: Not restricted to a specific design. Both modal and persistent styles are supported, as well as scrollable and non-scrollable widgets. +- **Supports nested navigation**: A sheet is able to have multiple pages and to navigate between the pages with motion animation for transitions. +- **Works with imperative & declarative Navigator API**: No special navigation mechanism is required. The traditional ways such as `Navigator.push` is supported and it works with Navigator 2.0 packages like go_route as well. +- **iOS flavor**: The modal sheets in the style of iOS 15 are supported. + +
+ +## For developers using Flutter 3.24+ + +If your project uses Flutter 3.24.0 or later, we recommend using the pre-release versions named `1.0.0-f324.x.x.x`. While you can still use the non-pre-release versions (e.g., `0.9.4`) with Flutter 3.24+, you may encounter issues related to the `PopScope` widget due to a breaking change in Flutter 3.24. There are no functional or API differences between the pre-release and non-pre-release versions, except that the pre-release versions require Flutter 3.24.0 or later. + +```yaml +dependencies: + # For projects that uses the pre-release versions (requires Flutter 3.24+) + smooth_sheets: ^1.0.0-f324.0.9.4 + # For projects that uses the non-pre-release versions + smooth_sheets: ^0.9.4 +``` + +
+Background + +This package previously used the `Route.onPopInvoked` method to invoke `PopScope.onPopInvoked` callbacks when users performed a swipe-to-dismiss gesture. However, these methods were deprecated in Flutter 3.24.0 as part of a [breaking change](https://docs.flutter.dev/release/breaking-changes/popscope-with-result) related to the `PopScope` widget. The problem is that `ModalRoute.onPopInvoked`, which was an override of `Route.onPopInvoked` and where `PopScope.onPopInvoked` callbacks were actually invoked, was removed. As a result, the `PopScope.onPopInvoked` callback is no longer invoked in Flutter 3.24+. These changes led to issues such as [#233](https://github.com/fujidaiti/smooth_sheets/issues/233). + +The only possible solution was to replace `Route.onPopInvoked` with `Route.onPopInvokedWithResult`, which was introduced in Flutter 3.24.0. However, migrating to the new API would require increasing the lower bound of the SDK constraint to 3.24.0. For those using an SDK version lower than 3.24, this change would be a significant breaking change. Ultimately, we decided to publish different versions for different SDK constraints to maintain backward compatibility. +
+ +
+ +## Migration guide + +- [0.9.x to 0.10.x](https://github.com/fujidaiti/smooth_sheets/blob/main/migrations/migration-guide-0.10.x.md) 🆕 +- [0.8.x to 0.9.x](https://github.com/fujidaiti/smooth_sheets/blob/main/migrations/migration-guide-0.9.x.md) + +See [here](https://github.com/fujidaiti/smooth_sheets/tree/main/migrations) for older versions. + +
+ +## Showcases + + + + + + + + + + + + + + + + + + +
+

AI Playlist Generator

+

An AI assistant that helps create a music playlist based on the user's preferences. See the cookbook for more details.

+

Key components:

+
    +
  • NavigationSheet
  • +
  • ModalSheetPage
  • +
  • DraggableNavigationSheetPage
  • +
  • ScrollableNavigationSheetPage
  • +
  • SheetDismissible
  • +
+
+

Safari app

+

A practical example of ios-style modal sheets. See the cookbook for more details.

+

Key components:

+
    +
  • CupertinoStackedTransition
  • +
  • CupertinoModalSheetRoute
  • +
+
+

Airbnb mobile app clone

+

A partial clone of the Airbnb mobile app. The user can drag the house list down to reveal the map behind it. See the cookbook for more details.

+

Key components:

+
    +
  • ScrollableSheet
  • +
  • SheetPhysics
  • +
  • SheetController
  • +
  • ExtentDrivenAnimation
  • +
+
+

Todo List

+

A simple Todo app that shows how a sheet handles the on-screen keyboard. See the cookbook for more details.

+

Used components:

+
    +
  • ScrollableSheet
  • +
  • SheetContentScaffold
  • +
  • SheetKeyboardDismissBehavior
  • +
  • SheetDismissible
  • +
  • StickyBottomBarVisibility
  • +
+
+ + + + +
+ +## Why use this? + +There are few packages on pub.dev that supports nested navigation with motion animation for page transitions. One of the great choices for this usecase is [wolt_modal_sheet](https://github.com/woltapp/wolt_modal_sheet), which this package is inspired by. Although smooth_sheet has similar features with wolt_modal_sheet, it does not intended to be a replacement of that package. Here is some differences between those 2 packages: + +| | wolt_modal_sheet | smooth_sheets | +|:----------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------:| +| Design | Based on Wolt's [design guideline](https://careers.wolt.com/en/blog/tech/an-overview-of-the-multi-page-scrollable-bottom-sheet-ui-design) | Not restricted to a specific design, fully customizable | +| Navigation mechanism | [Manage the page index in ValueNotifier](https://github.com/woltapp/wolt_modal_sheet#usage) | Works with built-in Navigator API (both of imperative and declarative) | +| Scrollable content | [Supported](https://github.com/woltapp/wolt_modal_sheet#scrollable-content) | Supported | +| Persistent sheets | Not supported | Supported | +| Screen size adaptation | [The sheet appears as a dialog on large screens](https://github.com/woltapp/wolt_modal_sheet#responsive-design) | Not supported | + +
+ +## Usage + +Several resources are available for learning the functionalities of this package. + +- Tutorials: See [example/lib/tutorial/](https://github.com/fujidaiti/smooth_sheets/tree/main/example/lib/tutorial) to learn the basic usage of the core components. +- Showcases: More practical examples are available in [example/lib/showcase/](https://github.com/fujidaiti/smooth_sheets/tree/main/example/lib/showcase). +- Documentation: WORK IN PROGRESS! Please see the source code for a while. + +
+ +## Ingredients + +This section provides descriptions for each core component and links to related resources for further learning. + +
+ +### SheetAnchor + +SheetAnchor represents the visible height of the sheet. It is used in a variety of situations, for example, to specify how much area of a sheet is initially visible at first build, or to limit the range of sheet dragging. + +
+ +### DraggableSheet + +
+ +
+ + +A sheet that can be dragged. The height will be equal to the content. The behavior of the sheet when over-dragged or under-dragged is determined by [SheetPhysics](#sheetphysics). Note that this widget does not work with scrollable widgets. Instead, use [ScrollableSheet](#scrollablesheet) for this usecase. + +See also: + +- [draggable_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/draggable_sheet.dart) for basic usage. + +
+ +### ScrollableSheet + +
+ +
+ + +A sheet that is similar to [DraggableSheet](#draggablesheet), but specifically designed to be integrated with scrollable widgets. It will begin to be dragged when the content is over-scrolled or under-scrolled. + +See also: + +- [scrollable_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/scrollable_sheet.dart) for basic usage. + +
+ +### NavigationSheet + +
+ +
+ + +A sheet that is able to have multiple pages and performs graceful motion animation when page transitions. It supports both of imperative Navigator API such as `Navigator.push`, and declarative API (Navigator 2.0). + +See also: + +- [declarative_navigation_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/declarative_navigation_sheet.dart), tutorial of integration with Navigator 2.0 using [go_router](https://pub.dev/packages/go_router) package. +- [imperative_navigation_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/imperative_navigation_sheet.dart), a tutorial of integration with imperative Navigator API. + +
+ +### ModalSheets + +
+ +
+ +A sheet can be displayed as a modal sheet using ModalSheetRoute for imperative navigation, or ModalSheetPage for declarative navigation. To enable the *swipe-to-dismiss* action, which allows the user to dismiss the sheet by a swiping-down gesture, set `swipeDismissible` to true. + +
+ +
+ +Furthermore, [the modal sheets in the style of iOS 15](https://medium.com/surf-dev/bottomsheet-in-ios-15-uisheetpresentationcontroller-and-its-capabilities-5e913661c9f) are also supported. For imperative navigation, use CupertinoModalSheetRoute, and for declarative navigation, use CupertinoModalSheetPage, respectively. + +See also: + +- [SwipeDismissSensitivity](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SwipeDismissSensitivity-class.html), which can be used to tweak the sensitivity of the swipe-to-dismiss action. +- [declarative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/declarative_modal_sheet.dart), a tutorial of integration with declarative navigation using [go_router](https://pub.dev/packages/go_router) package. +- [imperative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/imperative_modal_sheet.dart), a tutorial of integration with imperative Navigator API. +- [cupertino_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/cupertino_modal_sheet.dart), a tutorial of iOS style modal sheets. +- [ios_style_declarative_modal_navigation_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/ios_style_declarative_modal_navigation_sheet.dart), an example of iOS-style modal NavigationSheet with go_router. +- [showcase/todo_list](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/showcase/todo_list), which uses SheetDismissible to show a confirmation dialog when the user tries to discard the todo editing sheet without saving the content. + +
+ +### SheetPhysics + +
+ +
+ + +A physics determines how the sheet will behave when over-dragged or under-dragged, or when the user stops dragging. There are 3 predefined physics: + +- ClampingSheetPhysics: Prevents the sheet from reaching beyond the content bounds. +- BouncingSheetPhysics: Allows the sheet to go beyond the content bounds, but then bounce the sheet back to the edge of those bounds. Use [BouncingBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/BouncingBehavior-class.html) and its subclasses to tweak the bouncing behavior. +- SnappingSheetPhysics: Automatically snaps the sheet to a certain extent when the user stops dragging. + +These physics can be combined to create more complex behavior (e.g. bouncing behavior + snapping behavior). + +See also: + +- [sheet_physics.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/sheet_physics.dart) for basic usage. +- [bouncing_behaviors.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/bouncing_behaviors.dart), which shows how to tweak the bouncing behavior of BouncingSheetPhysics. + +
+ +### SheetController + +
+ +
+ + +Like [ScrollController](https://api.flutter.dev/flutter/widgets/ScrollController-class.html) for scrollable widget, the SheetController can be used to animate or observe the extent of a sheet. + +See also: + +- [sheet_controller.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/sheet_controller.dart) for basic usage. + +
+ +### SheetContentScaffold + +
+ +
+ + +A special kind of [Scaffold](https://api.flutter.dev/flutter/material/Scaffold-class.html) designed for use in a sheet. It has slots for an app bar and a bottom bar, similar to Scaffold. However, it differs in that its height reduces to fit the content widget. + +See also: + +- [SheetContentScaffold](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetContentScaffold-class.html), the API documentation. +- [BottomBarVisibility](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/BottomBarVisibility-class.html), which can be used to control the visibility of the bottom bar based on the sheet position. +- [tutorial/sheet_content_scaffold.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/sheet_content_scaffold.dart), which shows the basic usage of SheetContentScaffold. +- [tutorial/bottom_bar_visibility.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/bottom_bar_visibility.dart), which shows the basic usage of BottomBarVisibility widgets. + +
+ +### SheetDraggable + +
+ +
+ +SheetDraggable enables its child widget to act as a drag handle for the sheet. Typically, you will want to use this widget when placing non-scrollable widget(s) in a [ScrollableSheet](#scrollablesheet), since it only works with scrollable widgets, so you can't drag the sheet by touching a non-scrollable area. Try removing SheetDraggable and you will see that the drag handle doesn't work as it should. Note that SheetDraggable is not needed when using DraggableSheet since it implicitly wraps the child widget with SheetDraggable. + +See also: + +- [sheet_draggable.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/sheet_draggable.dart) for basic usage. + +
+ +### SheetPositionDrivenAnimation + +
+ +
+ +It is easy to create sheet extent driven animations by using SheetPositionDrivenAnimation, a special kind of [Animation](https://api.flutter.dev/flutter/animation/Animation-class.html) whose value changes from 0 to 1 as the sheet extent changes from 'startExtent' to 'endExtent'. + +See also: + +- [sheet_position_driven_animation](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/sheet_position_driven_animation.dart) for basic usage. +- [airbnb_mobile_app.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/showcase/airbnb_mobile_app.dart), which show how SheetPositionDrivenAnimation can be used to hide the bottom navigation bar and a FAB when the sheet is dragged down, and to show them when the sheet is dragged up again. + +
+ +### SheetNotification + +A sheet dispatches a [SheetNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetNotification-class.html) when its extent changes. This can be used to observe the extent of a descendant sheet from an ancestor widget. + +```dart +NotificationListener( + onNotification: (notification) { + debugPrint('${notification.metrics}'); + return false; + }, + child: DraggableSheet( + ... + ), +), +``` + +See also: + +- [SheetDragUpdateNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetDragUpdateNotification-class.html), which is dispatched when the sheet is dragged by the user. +- [SheetUpdateNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetUpdateNotification-class.html), which is dispatched when the sheet extent is updated by other than user interaction such as animation. +- [SheetOverflowNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetOverflowNotification-class.html), which is dispatched when the user tries to drag the sheet beyond its draggable bounds but the sheet has not changed its extent because its [SheetPhysics](#sheetphysics) does not allow it to be. +- [NotificationListener](https://api.flutter.dev/flutter/widgets/NotificationListener-class.html), which can be used to listen for the notifications in a subtree. + +
+ +### SheetKeyboardDismissBehavior + +
+ +
+
+ +[SheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetKeyboardDismissBehavior-class.html) determines when the sheet should dismiss the on-screen keyboard. This feature is similar to [ScrollViewKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/ScrollViewKeyboardDismissBehavior.html) for scrollable widgets. + +Although it is easy to create custom behaviors by implementing SheetKeyboardDismissBehavior interface, there are 3 types of predefined behaviors for convenience. + +- [DragSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard when the sheet is dragged. +- [DragDownSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragDownSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard only when the sheet is dragged down. +- [DragUpSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragUpSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard only when the sheet is dragged up. + +See also: + +- [tutorial/keyboard_dismiss_behavior.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/keyboard_dismiss_behavior.dart) for basic usage. + +
+ +## Questions + +If you have any questions, feel free to ask them on [the discussions page](https://github.com/fujidaiti/smooth_sheets/discussions). + +
+ +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +
+ +## Links + +- [Roadmap](https://github.com/fujidaiti/smooth_sheets/issues?q=is%3Aissue+is%3Aopen+label%3A%22feature+request%22) +- [API Documentation](https://pub.dev/documentation/smooth_sheets/latest/) +- [pub.dev](https://pub.dev/packages/smooth_sheets) +- [norelease.dev](https://pub.dev/publishers/norelease.dev/packages) + +
diff --git a/package/analysis_options.yaml b/analysis_options.yaml similarity index 92% rename from package/analysis_options.yaml rename to analysis_options.yaml index 1674b9f0..7c3057bb 100644 --- a/package/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,3 +12,4 @@ linter: cascade_invocations: false join_return_with_assignment: false prefer_relative_imports: true + unnecessary_parenthesis: false diff --git a/resources/cookbook-ai-playlist-generator.gif b/art/cookbook-ai-playlist-generator.gif similarity index 100% rename from resources/cookbook-ai-playlist-generator.gif rename to art/cookbook-ai-playlist-generator.gif diff --git a/resources/cookbook-airbnb-mobile-app.gif b/art/cookbook-airbnb-mobile-app.gif similarity index 100% rename from resources/cookbook-airbnb-mobile-app.gif rename to art/cookbook-airbnb-mobile-app.gif diff --git a/resources/cookbook-cupertino-modal-sheet.gif b/art/cookbook-cupertino-modal-sheet.gif similarity index 100% rename from resources/cookbook-cupertino-modal-sheet.gif rename to art/cookbook-cupertino-modal-sheet.gif diff --git a/resources/cookbook-declarative-navigation-sheet.gif b/art/cookbook-declarative-navigation-sheet.gif similarity index 100% rename from resources/cookbook-declarative-navigation-sheet.gif rename to art/cookbook-declarative-navigation-sheet.gif diff --git a/resources/cookbook-declerative-modal-sheet.gif b/art/cookbook-declerative-modal-sheet.gif similarity index 100% rename from resources/cookbook-declerative-modal-sheet.gif rename to art/cookbook-declerative-modal-sheet.gif diff --git a/resources/cookbook-draggable-sheet.gif b/art/cookbook-draggable-sheet.gif similarity index 100% rename from resources/cookbook-draggable-sheet.gif rename to art/cookbook-draggable-sheet.gif diff --git a/resources/cookbook-extent-driven-animation.gif b/art/cookbook-extent-driven-animation.gif similarity index 100% rename from resources/cookbook-extent-driven-animation.gif rename to art/cookbook-extent-driven-animation.gif diff --git a/resources/cookbook-imerative-modal-sheet.gif b/art/cookbook-imerative-modal-sheet.gif similarity index 100% rename from resources/cookbook-imerative-modal-sheet.gif rename to art/cookbook-imerative-modal-sheet.gif diff --git a/resources/cookbook-imperative-modal-sheet.gif b/art/cookbook-imperative-modal-sheet.gif similarity index 100% rename from resources/cookbook-imperative-modal-sheet.gif rename to art/cookbook-imperative-modal-sheet.gif diff --git a/resources/cookbook-imperative-navigation-sheet.gif b/art/cookbook-imperative-navigation-sheet.gif similarity index 100% rename from resources/cookbook-imperative-navigation-sheet.gif rename to art/cookbook-imperative-navigation-sheet.gif diff --git a/resources/cookbook-keyboard-dismiss-behavior.gif b/art/cookbook-keyboard-dismiss-behavior.gif similarity index 100% rename from resources/cookbook-keyboard-dismiss-behavior.gif rename to art/cookbook-keyboard-dismiss-behavior.gif diff --git a/resources/cookbook-safari.gif b/art/cookbook-safari.gif similarity index 100% rename from resources/cookbook-safari.gif rename to art/cookbook-safari.gif diff --git a/resources/cookbook-scrollable-sheet.gif b/art/cookbook-scrollable-sheet.gif similarity index 100% rename from resources/cookbook-scrollable-sheet.gif rename to art/cookbook-scrollable-sheet.gif diff --git a/resources/cookbook-sheet-content-scaffold.gif b/art/cookbook-sheet-content-scaffold.gif similarity index 100% rename from resources/cookbook-sheet-content-scaffold.gif rename to art/cookbook-sheet-content-scaffold.gif diff --git a/resources/cookbook-sheet-controller.gif b/art/cookbook-sheet-controller.gif similarity index 100% rename from resources/cookbook-sheet-controller.gif rename to art/cookbook-sheet-controller.gif diff --git a/resources/cookbook-sheet-draggable.gif b/art/cookbook-sheet-draggable.gif similarity index 100% rename from resources/cookbook-sheet-draggable.gif rename to art/cookbook-sheet-draggable.gif diff --git a/resources/cookbook-sheet-physics.gif b/art/cookbook-sheet-physics.gif similarity index 100% rename from resources/cookbook-sheet-physics.gif rename to art/cookbook-sheet-physics.gif diff --git a/resources/cookbook-todo-list.gif b/art/cookbook-todo-list.gif similarity index 100% rename from resources/cookbook-todo-list.gif rename to art/cookbook-todo-list.gif diff --git a/resources/index.md b/art/index.md similarity index 100% rename from resources/index.md rename to art/index.md diff --git a/package/resources/smooth-sheets-thumbnail.png b/art/smooth-sheets-thumbnail.png similarity index 100% rename from package/resources/smooth-sheets-thumbnail.png rename to art/smooth-sheets-thumbnail.png diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..75d8d60a --- /dev/null +++ b/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + mockito|mockBuilder: + generate_for: + - test/src/*.dart diff --git a/cookbook/lib/issue/issue131.dart b/cookbook/lib/issue/issue131.dart deleted file mode 100644 index e4f2b79a..00000000 --- a/cookbook/lib/issue/issue131.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:smooth_sheets/smooth_sheets.dart'; - -/// Issue [#131](https://github.com/fujidaiti/smooth_sheets/issues/131): -/// Unwanted bouncing effect when opening keyboard on NavigationSheet -void main() { - runApp(const Issue131()); -} - -class Issue131 extends StatelessWidget { - const Issue131({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp.router(routerConfig: router); - } -} - -final sheetTransitionObserver = NavigationSheetTransitionObserver(); - -final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const Home(), - routes: [ - ShellRoute( - observers: [sheetTransitionObserver], - pageBuilder: (context, state, navigator) { - return ModalSheetPage( - child: ModalSheet( - navigator: navigator, - transitionObserver: sheetTransitionObserver, - ), - ); - }, - routes: [ - GoRoute( - path: 'intro', - pageBuilder: (context, state) { - return const DraggableNavigationSheetPage( - child: SheetContent(), - ); - }, - ) - ], - ) - ], - ), - ], -); - -class Home extends StatelessWidget { - const Home({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => context.go('/intro'), - child: const Text('Show modal sheet'), - ), - ), - ); - } -} - -class ModalSheet extends StatelessWidget { - const ModalSheet({ - super.key, - required this.transitionObserver, - required this.navigator, - }); - - final NavigationSheetTransitionObserver transitionObserver; - final Widget navigator; - - @override - Widget build(BuildContext context) { - return NavigationSheet( - transitionObserver: sheetTransitionObserver, - child: ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: navigator, - ), - ); - } -} - -class SheetContent extends StatelessWidget { - const SheetContent({super.key}); - - @override - Widget build(BuildContext context) { - return SheetContentScaffold( - body: SizedBox( - height: 300, - child: Center( - child: TextFormField(), - ), - ), - ); - } -} diff --git a/cookbook/lib/issue/issue137.dart b/cookbook/lib/issue/issue137.dart deleted file mode 100644 index 465074c5..00000000 --- a/cookbook/lib/issue/issue137.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:smooth_sheets/smooth_sheets.dart'; - -/// issue [#137](https://github.com/fujidaiti/smooth_sheets/issues/137): -/// SheetDismissible not working with NavigationSheet -void main() { - runApp(const MaterialApp( - home: HomePage(), - )); -} - -class HomePage extends StatelessWidget { - const HomePage({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Smooth Sheets Example'), - ), - body: Center( - child: ElevatedButton( - onPressed: () { - BaseModal.show(context); - }, - child: const Text('Show Modal'), - ), - ), - ); - } -} - -class BaseModal extends StatelessWidget { - const BaseModal({super.key}); - - static Future show(BuildContext context) async { - return await Navigator.push( - context, - ModalSheetRoute( - swipeDismissible: true, - builder: (context) => const BaseModal(), - )); - } - - @override - Widget build(BuildContext context) { - final transitionObserver = NavigationSheetTransitionObserver(); - - final nestedNavigator = Navigator( - observers: [transitionObserver], - onGenerateInitialRoutes: (navigator, initialRoute) { - return [ - ScrollableNavigationSheetRoute( - builder: (context) { - return const BasePage(); - }, - ), - ]; - }, - ); - - return SafeArea( - bottom: false, - child: NavigationSheet( - transitionObserver: transitionObserver, - child: Material( - color: Colors.white, - clipBehavior: Clip.antiAlias, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - child: nestedNavigator, - ), - ), - ); - } -} - -class BasePage extends StatelessWidget { - const BasePage({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 300, - color: Colors.amber, - ), - const SizedBox( - height: 10, - ) - ], - ), - )); - } -} diff --git a/cookbook/lib/issue/issue80.dart b/cookbook/lib/issue/issue80.dart deleted file mode 100644 index 976b0555..00000000 --- a/cookbook/lib/issue/issue80.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:smooth_sheets/smooth_sheets.dart'; - -/// issue [#80](https://github.com/fujidaiti/smooth_sheets/issues/80): -/// SheetDismissible not working with infinite looping scroll widget -void main() { - runApp( - const CupertinoApp( - home: _Home(), - ), - ); -} - -class _Home extends StatelessWidget { - const _Home(); - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - child: Center( - child: CupertinoButton.filled( - onPressed: () => showTimePickerSheet(context), - child: const Text('Show Time Picker Sheet'), - ), - ), - ); - } -} - -void showTimePickerSheet(BuildContext context) { - final modalRoute = ModalSheetRoute( - swipeDismissible: true, - builder: (context) => DraggableSheet( - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - automaticallyImplyLeading: false, - middle: const Text('Time Picker'), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), - child: const Icon(CupertinoIcons.xmark), - ), - ), - child: SafeArea( - child: SizedBox( - height: 300, - child: CupertinoTheme( - data: const CupertinoThemeData( - textTheme: CupertinoTextThemeData( - dateTimePickerTextStyle: TextStyle(fontSize: 56), - ), - ), - child: CupertinoDatePicker( - initialDateTime: DateTime.now(), - mode: CupertinoDatePickerMode.time, - use24hFormat: true, - onDateTimeChanged: (value) {}, - itemExtent: 80, - ), - ), - ), - ), - ), - ), - ), - ); - - Navigator.push(context, modalRoute); -} diff --git a/cookbook/.gitignore b/example/.gitignore similarity index 94% rename from cookbook/.gitignore rename to example/.gitignore index 6bc4ab47..38abd079 100644 --- a/cookbook/.gitignore +++ b/example/.gitignore @@ -17,7 +17,6 @@ migrate_working_dir/ .idea/ # Flutter/Dart/Pub related -/pubspec.lock **/doc/api/ .dart_tool/ .packages diff --git a/cookbook/.metadata b/example/.metadata similarity index 100% rename from cookbook/.metadata rename to example/.metadata diff --git a/cookbook/README.md b/example/README.md similarity index 100% rename from cookbook/README.md rename to example/README.md diff --git a/cookbook/analysis_options.yaml b/example/analysis_options.yaml similarity index 100% rename from cookbook/analysis_options.yaml rename to example/analysis_options.yaml diff --git a/cookbook/android/.gitignore b/example/android/.gitignore similarity index 100% rename from cookbook/android/.gitignore rename to example/android/.gitignore diff --git a/cookbook/android/app/build.gradle b/example/android/app/build.gradle similarity index 100% rename from cookbook/android/app/build.gradle rename to example/android/app/build.gradle diff --git a/cookbook/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from cookbook/android/app/src/debug/AndroidManifest.xml rename to example/android/app/src/debug/AndroidManifest.xml diff --git a/cookbook/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from cookbook/android/app/src/main/AndroidManifest.xml rename to example/android/app/src/main/AndroidManifest.xml diff --git a/cookbook/android/app/src/main/kotlin/com/example/cookbook/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/cookbook/MainActivity.kt similarity index 100% rename from cookbook/android/app/src/main/kotlin/com/example/cookbook/MainActivity.kt rename to example/android/app/src/main/kotlin/com/example/cookbook/MainActivity.kt diff --git a/cookbook/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from cookbook/android/app/src/main/res/drawable-v21/launch_background.xml rename to example/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/cookbook/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from cookbook/android/app/src/main/res/drawable/launch_background.xml rename to example/android/app/src/main/res/drawable/launch_background.xml diff --git a/cookbook/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from cookbook/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/cookbook/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from cookbook/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/cookbook/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from cookbook/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/cookbook/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from cookbook/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/cookbook/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from cookbook/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/cookbook/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from cookbook/android/app/src/main/res/values-night/styles.xml rename to example/android/app/src/main/res/values-night/styles.xml diff --git a/cookbook/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from cookbook/android/app/src/main/res/values/styles.xml rename to example/android/app/src/main/res/values/styles.xml diff --git a/cookbook/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from cookbook/android/app/src/profile/AndroidManifest.xml rename to example/android/app/src/profile/AndroidManifest.xml diff --git a/cookbook/android/build.gradle b/example/android/build.gradle similarity index 100% rename from cookbook/android/build.gradle rename to example/android/build.gradle diff --git a/cookbook/android/gradle.properties b/example/android/gradle.properties similarity index 100% rename from cookbook/android/gradle.properties rename to example/android/gradle.properties diff --git a/cookbook/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 74% rename from cookbook/android/gradle/wrapper/gradle-wrapper.properties rename to example/android/gradle/wrapper/gradle-wrapper.properties index 3c472b99..09523c0e 100644 --- a/cookbook/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/cookbook/android/settings.gradle b/example/android/settings.gradle similarity index 92% rename from cookbook/android/settings.gradle rename to example/android/settings.gradle index 7cd71285..af1ec35d 100644 --- a/cookbook/android/settings.gradle +++ b/example/android/settings.gradle @@ -23,7 +23,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version '7.4.2' apply false } include ":app" diff --git a/cookbook/assets/apple_website.png b/example/assets/apple_website.png similarity index 100% rename from cookbook/assets/apple_website.png rename to example/assets/apple_website.png diff --git a/cookbook/assets/fake_map.png b/example/assets/fake_map.png similarity index 100% rename from cookbook/assets/fake_map.png rename to example/assets/fake_map.png diff --git a/cookbook/ios/.gitignore b/example/ios/.gitignore similarity index 100% rename from cookbook/ios/.gitignore rename to example/ios/.gitignore diff --git a/cookbook/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from cookbook/ios/Flutter/AppFrameworkInfo.plist rename to example/ios/Flutter/AppFrameworkInfo.plist diff --git a/cookbook/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from cookbook/ios/Flutter/Debug.xcconfig rename to example/ios/Flutter/Debug.xcconfig diff --git a/cookbook/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig similarity index 100% rename from cookbook/ios/Flutter/Release.xcconfig rename to example/ios/Flutter/Release.xcconfig diff --git a/cookbook/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from cookbook/ios/Runner.xcodeproj/project.pbxproj rename to example/ios/Runner.xcodeproj/project.pbxproj diff --git a/cookbook/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from cookbook/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/cookbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from cookbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/cookbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from cookbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/cookbook/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from cookbook/ios/Runner.xcworkspace/contents.xcworkspacedata rename to example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/cookbook/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from cookbook/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/cookbook/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from cookbook/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/cookbook/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift similarity index 100% rename from cookbook/ios/Runner/AppDelegate.swift rename to example/ios/Runner/AppDelegate.swift diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from cookbook/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/cookbook/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from cookbook/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/cookbook/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from cookbook/ios/Runner/Base.lproj/Main.storyboard rename to example/ios/Runner/Base.lproj/Main.storyboard diff --git a/cookbook/ios/Runner/Info.plist b/example/ios/Runner/Info.plist similarity index 100% rename from cookbook/ios/Runner/Info.plist rename to example/ios/Runner/Info.plist diff --git a/cookbook/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from cookbook/ios/Runner/Runner-Bridging-Header.h rename to example/ios/Runner/Runner-Bridging-Header.h diff --git a/cookbook/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift similarity index 100% rename from cookbook/ios/RunnerTests/RunnerTests.swift rename to example/ios/RunnerTests/RunnerTests.swift diff --git a/cookbook/lib/showcase/ai_playlist_generator.dart b/example/lib/showcase/ai_playlist_generator.dart similarity index 98% rename from cookbook/lib/showcase/ai_playlist_generator.dart rename to example/lib/showcase/ai_playlist_generator.dart index a0188d78..723069b1 100644 --- a/cookbook/lib/showcase/ai_playlist_generator.dart +++ b/example/lib/showcase/ai_playlist_generator.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'dart:io'; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -8,7 +9,7 @@ import 'package:smooth_sheets/smooth_sheets.dart'; void main() { // Make the system navigation bar transparent on Android. - if (Platform.isAndroid) { + if (defaultTargetPlatform == TargetPlatform.android) { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setEnabledSystemUIMode( SystemUiMode.edgeToEdge, @@ -104,9 +105,9 @@ final _confirmRoute = GoRoute( path: 'confirm', pageBuilder: (context, state) { return const ScrollableNavigationSheetPage( - initialExtent: Extent.proportional(0.7), - minExtent: Extent.proportional(0.7), - physics: StretchingSheetPhysics( + initialPosition: SheetAnchor.proportional(0.7), + minPosition: SheetAnchor.proportional(0.7), + physics: BouncingSheetPhysics( parent: SnappingSheetPhysics(), ), child: _ConfirmPage(), diff --git a/cookbook/lib/showcase/airbnb_mobile_app.dart b/example/lib/showcase/airbnb_mobile_app.dart similarity index 92% rename from cookbook/lib/showcase/airbnb_mobile_app.dart rename to example/lib/showcase/airbnb_mobile_app.dart index 63ed0944..c31d0485 100644 --- a/cookbook/lib/showcase/airbnb_mobile_app.dart +++ b/example/lib/showcase/airbnb_mobile_app.dart @@ -60,7 +60,7 @@ class _Home extends StatelessWidget { return DefaultTabController( length: _AppBar.tabs.length, // Provides a SheetController to the descendant widgets - // to perform some sheet extent driven animations. + // to perform some sheet position driven animations. // The sheet will look up and use this controller unless // another one is manually specified in the constructor. // The descendant widgets can also get this controller by @@ -80,11 +80,11 @@ class _MapButton extends StatelessWidget { final sheetController = DefaultSheetController.of(context); void onPressed() { - final metrics = sheetController.value; + final metrics = sheetController.metrics; if (metrics.hasDimensions) { // Collapse the sheet to reveal the map behind. sheetController.animateTo( - Extent.pixels(metrics.minPixels), + SheetAnchor.pixels(metrics.minPixels), curve: Curves.fastOutSlowIn, ); } @@ -97,19 +97,19 @@ class _MapButton extends StatelessWidget { icon: const Icon(Icons.map), ); - // It is easy to create sheet extent driven animations - // by using 'ExtentDrivenAnimation', a special kind of + // It is easy to create sheet position driven animations + // by using 'PositionDrivenAnimation', a special kind of // 'Animation' whose value changes from 0 to 1 as - // the sheet extent changes from 'startExtent' to 'endExtent'. - final animation = ExtentDrivenAnimation( + // the sheet position changes from 'startPosition' to 'endPosition'. + final animation = SheetPositionDrivenAnimation( controller: DefaultSheetController.of(context), // The initial value of the animation is required - // since the sheet extent is not available at the first build. + // since the sheet position is not available at the first build. initialValue: 1, - // If null, the minimum extent will be used. (Default) - startExtent: null, - // If null, the maximum extent will be used. (Default) - endExtent: null, + // If null, the minimum position will be used. (Default) + startPosition: null, + // If null, the maximum position will be used. (Default) + endPosition: null, ).drive(CurveTween(curve: Curves.easeInExpo)); // Hide the button when the sheet is dragged down. @@ -155,8 +155,8 @@ class _ContentSheet extends StatelessWidget { final appbarHeight = MediaQuery.of(context).padding.top; final handleHeight = const _ContentSheetHandle().preferredSize.height; final sheetHeight = parentHeight - appbarHeight + handleHeight; - final minSheetExtent = - Extent.pixels(handleHeight + systemUiInsets.bottom); + final minSheetPosition = + SheetAnchor.pixels(handleHeight + systemUiInsets.bottom); const sheetShape = RoundedRectangleBorder( borderRadius: BorderRadius.vertical( @@ -164,12 +164,12 @@ class _ContentSheet extends StatelessWidget { ), ); - final sheetPhysics = StretchingSheetPhysics( + final sheetPhysics = BouncingSheetPhysics( parent: SnappingSheetPhysics( - snappingBehavior: SnapToNearest( - snapTo: [ - minSheetExtent, - const Extent.proportional(1), + behavior: SnapToNearest( + anchors: [ + minSheetPosition, + const SheetAnchor.proportional(1), ], ), ), @@ -177,7 +177,7 @@ class _ContentSheet extends StatelessWidget { return ScrollableSheet( physics: sheetPhysics, - minExtent: minSheetExtent, + minPosition: minSheetPosition, child: SizedBox( height: sheetHeight, child: const Card( @@ -261,7 +261,7 @@ class _HouseList extends StatelessWidget { // Hide the list when the sheet is dragged down. return FadeTransition( - opacity: ExtentDrivenAnimation( + opacity: SheetPositionDrivenAnimation( controller: DefaultSheetController.of(context), initialValue: 1, ).drive( @@ -453,7 +453,7 @@ class _BottomNavigationBar extends StatelessWidget { // Hide the navigation bar when the sheet is dragged down. return SlideTransition( - position: ExtentDrivenAnimation( + position: SheetPositionDrivenAnimation( controller: DefaultSheetController.of(context), initialValue: 1, ).drive( diff --git a/cookbook/lib/showcase/safari/actions.dart b/example/lib/showcase/safari/actions.dart similarity index 100% rename from cookbook/lib/showcase/safari/actions.dart rename to example/lib/showcase/safari/actions.dart diff --git a/cookbook/lib/showcase/safari/bookmark.dart b/example/lib/showcase/safari/bookmark.dart similarity index 64% rename from cookbook/lib/showcase/safari/bookmark.dart rename to example/lib/showcase/safari/bookmark.dart index 3102eb8d..cfbe9a21 100644 --- a/cookbook/lib/showcase/safari/bookmark.dart +++ b/example/lib/showcase/safari/bookmark.dart @@ -27,32 +27,34 @@ class EditBookmarkSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return DraggableSheet( - keyboardDismissBehavior: const SheetKeyboardDismissBehavior.onDragDown(), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: SheetContentScaffold( - backgroundColor: CupertinoColors.systemGroupedBackground, - appBar: CupertinoAppBar( - title: const Text('Add Bookmark'), - leading: CupertinoButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - trailing: CupertinoButton( - onPressed: () => - Navigator.popUntil(context, (route) => route.isFirst), - child: const Text('Save'), + return SheetKeyboardDismissible( + dismissBehavior: const SheetKeyboardDismissBehavior.onDragDown(), + child: DraggableSheet( + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SheetContentScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + appBar: CupertinoAppBar( + title: const Text('Add Bookmark'), + leading: CupertinoButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + trailing: CupertinoButton( + onPressed: () => + Navigator.popUntil(context, (route) => route.isFirst), + child: const Text('Save'), + ), ), - ), - body: SizedBox.expand( - child: CupertinoListSection.insetGrouped( - children: [ - _BookmarkEditor( - pageUrl: pageUrl, - faviconUrl: faviconUrl, - ), - ], + body: SizedBox.expand( + child: CupertinoListSection.insetGrouped( + children: [ + _BookmarkEditor( + pageUrl: pageUrl, + faviconUrl: faviconUrl, + ), + ], + ), ), ), ), diff --git a/cookbook/lib/showcase/safari/common.dart b/example/lib/showcase/safari/common.dart similarity index 100% rename from cookbook/lib/showcase/safari/common.dart rename to example/lib/showcase/safari/common.dart diff --git a/cookbook/lib/showcase/safari/home.dart b/example/lib/showcase/safari/home.dart similarity index 100% rename from cookbook/lib/showcase/safari/home.dart rename to example/lib/showcase/safari/home.dart diff --git a/cookbook/lib/showcase/safari/main.dart b/example/lib/showcase/safari/main.dart similarity index 100% rename from cookbook/lib/showcase/safari/main.dart rename to example/lib/showcase/safari/main.dart diff --git a/cookbook/lib/showcase/safari/menu.dart b/example/lib/showcase/safari/menu.dart similarity index 96% rename from cookbook/lib/showcase/safari/menu.dart rename to example/lib/showcase/safari/menu.dart index 6d1e192b..fc6f0857 100644 --- a/cookbook/lib/showcase/safari/menu.dart +++ b/example/lib/showcase/safari/menu.dart @@ -22,11 +22,11 @@ class MenuSheet extends StatelessWidget { @override Widget build(BuildContext context) { - const halfWayExtent = Extent.proportional(0.5); + const halfWayPosition = SheetAnchor.proportional(0.5); return ScrollableSheet( - initialExtent: halfWayExtent, - minExtent: halfWayExtent, - physics: const StretchingSheetPhysics( + initialPosition: halfWayPosition, + minPosition: halfWayPosition, + physics: const BouncingSheetPhysics( parent: SnappingSheetPhysics(), ), child: ClipRRect( @@ -152,7 +152,7 @@ class _MenuListItem extends StatelessWidget { trailing: Icon(icon, color: CupertinoColors.black), onTap: () { DefaultSheetController.maybeOf(context) - ?.animateTo(const Extent.proportional(1)); + ?.animateTo(const SheetAnchor.proportional(1)); showEditBookmarkSheet(context); }, ); diff --git a/cookbook/lib/showcase/todo_list/main.dart b/example/lib/showcase/todo_list/main.dart similarity index 100% rename from cookbook/lib/showcase/todo_list/main.dart rename to example/lib/showcase/todo_list/main.dart diff --git a/cookbook/lib/showcase/todo_list/models.dart b/example/lib/showcase/todo_list/models.dart similarity index 100% rename from cookbook/lib/showcase/todo_list/models.dart rename to example/lib/showcase/todo_list/models.dart diff --git a/cookbook/lib/showcase/todo_list/todo_editor.dart b/example/lib/showcase/todo_list/todo_editor.dart similarity index 93% rename from cookbook/lib/showcase/todo_list/todo_editor.dart rename to example/lib/showcase/todo_list/todo_editor.dart index 23287447..5b96a5be 100644 --- a/cookbook/lib/showcase/todo_list/todo_editor.dart +++ b/example/lib/showcase/todo_list/todo_editor.dart @@ -144,21 +144,22 @@ class _TodoEditorState extends State { child: PopScope( canPop: false, onPopInvoked: onPopInvoked, - child: ScrollableSheet( - keyboardDismissBehavior: - const SheetKeyboardDismissBehavior.onDragDown( + child: SheetKeyboardDismissible( + dismissBehavior: const SheetKeyboardDismissBehavior.onDragDown( isContentScrollAware: true, ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: sheetShape, - child: SheetContentScaffold( - resizeBehavior: const ResizeScaffoldBehavior.avoidBottomInset( - // Make the bottom bar visible when the keyboard is open. - maintainBottomBar: true, + child: ScrollableSheet( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: sheetShape, + child: SheetContentScaffold( + resizeBehavior: const ResizeScaffoldBehavior.avoidBottomInset( + // Make the bottom bar visible when the keyboard is open. + maintainBottomBar: true, + ), + body: body, + bottomBar: bottomBar, ), - body: body, - bottomBar: bottomBar, ), ), ), diff --git a/cookbook/lib/tutorial/bottom_bar_visibility.dart b/example/lib/tutorial/bottom_bar_visibility.dart similarity index 92% rename from cookbook/lib/tutorial/bottom_bar_visibility.dart rename to example/lib/tutorial/bottom_bar_visibility.dart index f2ee5afc..be8fd608 100644 --- a/cookbook/lib/tutorial/bottom_bar_visibility.dart +++ b/example/lib/tutorial/bottom_bar_visibility.dart @@ -133,14 +133,14 @@ class _ExampleSheet extends StatelessWidget { ), ); - const minSize = Extent.proportional(0.3); - const halfSize = Extent.proportional(0.5); - const fullSize = Extent.proportional(1); + const minSize = SheetAnchor.proportional(0.3); + const halfSize = SheetAnchor.proportional(0.5); + const fullSize = SheetAnchor.proportional(1); - final multiStopPhysics = StretchingSheetPhysics( + const multiStopPhysics = BouncingSheetPhysics( parent: SnappingSheetPhysics( - snappingBehavior: SnapToNearest( - snapTo: const [minSize, halfSize, fullSize], + behavior: SnapToNearest( + anchors: [minSize, halfSize, fullSize], ), ), ); @@ -148,8 +148,8 @@ class _ExampleSheet extends StatelessWidget { return SafeArea( bottom: false, child: DraggableSheet( - minExtent: minSize, - initialExtent: halfSize, + minPosition: minSize, + initialPosition: halfSize, physics: multiStopPhysics, child: SheetContentScaffold( appBar: AppBar(), @@ -166,7 +166,7 @@ class _ExampleSheet extends StatelessWidget { getIsVisible: (metrics) { // The bottom bar is visible when at least 50% of the sheet is visible. return metrics.pixels >= - const Extent.proportional(0.5) + const SheetAnchor.proportional(0.5) .resolve(metrics.contentSize); }, child: bottomBar, diff --git a/example/lib/tutorial/bouncing_behaviors.dart b/example/lib/tutorial/bouncing_behaviors.dart new file mode 100644 index 00000000..1d83e7be --- /dev/null +++ b/example/lib/tutorial/bouncing_behaviors.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const MaterialApp(home: _Home())); +} + +class _Home extends StatelessWidget { + const _Home(); + + @override + Widget build(BuildContext context) { + void showModalSheet(Widget sheet) { + Navigator.push(context, ModalSheetRoute(builder: (_) => sheet)); + } + + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ListTile( + title: const Text('FixedBouncingBehavior'), + subtitle: const Text('with ScrollableSheet'), + onTap: () => showModalSheet( + const _ScrollableSheet( + behavior: FixedBouncingBehavior( + // Allows the sheet position to exceed the content bounds + // by ±10% of the content height. + SheetAnchor.proportional(0.1), + ), + ), + ), + ), + ListTile( + title: const Text('FixedBouncingBehavior'), + subtitle: const Text('with DraggableSheet'), + onTap: () => showModalSheet( + const _DraggableSheet( + behavior: FixedBouncingBehavior( + // Allows the sheet position to exceed the content bounds by ±50 pixels. + SheetAnchor.pixels(50), + ), + ), + ), + ), + ListTile( + title: const Text('DirectionAwareBouncingBehavior'), + subtitle: const Text('with ScrollableSheet'), + onTap: () => showModalSheet( + const _ScrollableSheet( + behavior: DirectionAwareBouncingBehavior( + // Allows the sheet position to exceed the content bounds by 10 pixels + // when dragging the sheet upwards, and by ±30% of the content height + // when dragging it downwards. + upward: SheetAnchor.pixels(20), + downward: SheetAnchor.proportional(0.3), + ), + ), + ), + ), + ListTile( + title: const Text('DirectionAwareBouncingBehavior'), + subtitle: const Text('with DraggableSheet'), + onTap: () => showModalSheet( + const _DraggableSheet( + behavior: DirectionAwareBouncingBehavior( + // Allows the sheet to bounce only when dragging it downwards. + upward: SheetAnchor.pixels(0), + downward: SheetAnchor.proportional(0.1), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ScrollableSheet extends StatelessWidget { + const _ScrollableSheet({required this.behavior}); + + final BouncingBehavior behavior; + + @override + Widget build(BuildContext context) { + return ScrollableSheet( + physics: BouncingSheetPhysics(behavior: behavior), + child: Material( + color: Colors.white, + child: SizedBox( + height: 500, + child: ListView( + children: List.generate( + 30, + (index) => ListTile(title: Text('Item $index')), + ), + ), + ), + ), + ); + } +} + +class _DraggableSheet extends StatelessWidget { + const _DraggableSheet({required this.behavior}); + + final BouncingBehavior behavior; + + @override + Widget build(BuildContext context) { + return DraggableSheet( + physics: BouncingSheetPhysics(behavior: behavior), + child: Container( + height: 500, + width: double.infinity, + color: Colors.white, + ), + ); + } +} diff --git a/cookbook/lib/tutorial/cupertino_modal_sheet.dart b/example/lib/tutorial/cupertino_modal_sheet.dart similarity index 86% rename from cookbook/lib/tutorial/cupertino_modal_sheet.dart rename to example/lib/tutorial/cupertino_modal_sheet.dart index 088af809..ac6f315c 100644 --- a/cookbook/lib/tutorial/cupertino_modal_sheet.dart +++ b/example/lib/tutorial/cupertino_modal_sheet.dart @@ -48,7 +48,13 @@ void _showModalSheet(BuildContext context, {required bool isFullScreen}) { // Use `CupertinoModalSheetRoute` to show an ios 15 style modal sheet. // For declarative navigation (Navigator 2.0), use `CupertinoModalSheetPage` instead. final modalRoute = CupertinoModalSheetRoute( - swipeDismissible: true, // Enable the swipe-to-dismiss behavior. + // Enable the swipe-to-dismiss behavior. + swipeDismissible: true, + // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior. + swipeDismissSensitivity: const SwipeDismissSensitivity( + minFlingVelocityRatio: 2.0, + minDragDistance: 300.0, + ), builder: (context) => switch (isFullScreen) { true => const _FullScreenSheet(), false => const _HalfScreenSheet(), @@ -64,11 +70,11 @@ class _HalfScreenSheet extends StatelessWidget { @override Widget build(BuildContext context) { // `CupertinoStackedTransition` won't start the transition animation until - // the visible height of a modal sheet (the extent) exceeds 50% of the screen height. + // the visible height of a modal sheet (the position) exceeds 50% of the screen height. return const DraggableSheet( - initialExtent: Extent.proportional(0.5), - minExtent: Extent.proportional(0.5), - physics: StretchingSheetPhysics( + initialPosition: SheetAnchor.proportional(0.5), + minPosition: SheetAnchor.proportional(0.5), + physics: BouncingSheetPhysics( parent: SnappingSheetPhysics(), ), child: _SheetContent(), @@ -114,7 +120,7 @@ class _SheetContent extends StatelessWidget { // `DefaultSheetController.of` is a handy way to obtain a `SheetController` // that is exposed by the parent `CupertinoModalSheetRoute`. DefaultSheetController.maybeOf(context) - ?.animateTo(const Extent.proportional(1)); + ?.animateTo(const SheetAnchor.proportional(1)); _showModalSheet(context, isFullScreen: true); }, child: const Text('Stack'), diff --git a/cookbook/lib/tutorial/declarative_modal_sheet.dart b/example/lib/tutorial/declarative_modal_sheet.dart similarity index 91% rename from cookbook/lib/tutorial/declarative_modal_sheet.dart rename to example/lib/tutorial/declarative_modal_sheet.dart index 5f734beb..99e94d18 100644 --- a/cookbook/lib/tutorial/declarative_modal_sheet.dart +++ b/example/lib/tutorial/declarative_modal_sheet.dart @@ -26,6 +26,11 @@ final _router = GoRouter( key: state.pageKey, // Enable the swipe-to-dismiss behavior. swipeDismissible: true, + // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior. + swipeDismissSensitivity: const SwipeDismissSensitivity( + minFlingVelocityRatio: 2.0, + minDragDistance: 200.0, + ), child: const _ExampleSheet(), ); }, diff --git a/cookbook/lib/tutorial/declarative_navigation_sheet.dart b/example/lib/tutorial/declarative_navigation_sheet.dart similarity index 99% rename from cookbook/lib/tutorial/declarative_navigation_sheet.dart rename to example/lib/tutorial/declarative_navigation_sheet.dart index 08ed1573..3b3e0022 100644 --- a/cookbook/lib/tutorial/declarative_navigation_sheet.dart +++ b/example/lib/tutorial/declarative_navigation_sheet.dart @@ -13,7 +13,7 @@ void main() { } // NavigationSheet requires a special NavigatorObserver in order to -// smoothly change its extent during a route transition. +// smoothly change its position during a route transition. final transitionObserver = NavigationSheetTransitionObserver(); // To use declarative navigation, we utilize the 'go_router' package. diff --git a/cookbook/lib/tutorial/draggable_sheet.dart b/example/lib/tutorial/draggable_sheet.dart similarity index 100% rename from cookbook/lib/tutorial/draggable_sheet.dart rename to example/lib/tutorial/draggable_sheet.dart diff --git a/cookbook/lib/tutorial/extent_driven_animation.dart b/example/lib/tutorial/extent_driven_animation.dart similarity index 76% rename from cookbook/lib/tutorial/extent_driven_animation.dart rename to example/lib/tutorial/extent_driven_animation.dart index c0bedfd7..6e71884f 100644 --- a/cookbook/lib/tutorial/extent_driven_animation.dart +++ b/example/lib/tutorial/extent_driven_animation.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:smooth_sheets/smooth_sheets.dart'; void main() { - runApp(const _ExtentDrivenAnimationExample()); + runApp(const _PositionDrivenAnimationExample()); } -class _ExtentDrivenAnimationExample extends StatelessWidget { - const _ExtentDrivenAnimationExample(); +class _PositionDrivenAnimationExample extends StatelessWidget { + const _PositionDrivenAnimationExample(); @override Widget build(BuildContext context) { @@ -20,7 +20,7 @@ class _ExampleScaffold extends StatelessWidget { @override Widget build(BuildContext context) { // Provides a SheetController to the descendant widgets - // to perform some sheet extent driven animations. + // to perform some sheet position driven animations. // The sheet will look up and use this controller unless // another one is manually specified in the constructor. // The descendant widgets can also get this controller by @@ -48,18 +48,18 @@ class _ExampleSheet extends StatelessWidget { @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; - final minExtent = Extent.pixels(56 + bottomPadding); + final minPosition = SheetAnchor.pixels(56 + bottomPadding); - final physics = StretchingSheetPhysics( + final physics = BouncingSheetPhysics( parent: SnappingSheetPhysics( - snappingBehavior: SnapToNearest( - snapTo: [minExtent, const Extent.proportional(1)], + behavior: SnapToNearest( + anchors: [minPosition, const SheetAnchor.proportional(1)], ), ), ); return DraggableSheet( - minExtent: minExtent, + minPosition: minPosition, physics: physics, child: Card( margin: EdgeInsets.zero, @@ -84,19 +84,19 @@ class _BottomAppBar extends StatelessWidget { // Lookup the nearest controller. final controller = DefaultSheetController.of(context); - // It is easy to create sheet extent driven animations - // by using 'ExtentDrivenAnimation', a special kind of + // It is easy to create sheet position driven animations + // by using 'PositionDrivenAnimation', a special kind of // 'Animation' whose value changes from 0 to 1 as - // the sheet extent changes from 'startExtent' to 'endExtent'. - final animation = ExtentDrivenAnimation( + // the sheet position changes from 'startPosition' to 'endPosition'. + final animation = SheetPositionDrivenAnimation( controller: controller, // The initial value of the animation is required - // since the sheet extent is not available at the first build. + // since the sheet position is not available at the first build. initialValue: 1, - // If null, the minimum extent will be used. (Default) - startExtent: null, - // If null, the maximum extent will be used. (Default) - endExtent: null, + // If null, the minimum position will be used. (Default) + startPosition: null, + // If null, the maximum position will be used. (Default) + endPosition: null, ); final bottomAppBar = BottomAppBar( @@ -134,7 +134,7 @@ class _RotatedFlutterLogo extends StatelessWidget { @override Widget build(BuildContext context) { final logo = RotationTransition( - turns: ExtentDrivenAnimation( + turns: SheetPositionDrivenAnimation( controller: DefaultSheetController.of(context), initialValue: 1, ), diff --git a/cookbook/lib/tutorial/imperative_modal_sheet.dart b/example/lib/tutorial/imperative_modal_sheet.dart similarity index 90% rename from cookbook/lib/tutorial/imperative_modal_sheet.dart rename to example/lib/tutorial/imperative_modal_sheet.dart index 1c6ff82e..13aea697 100644 --- a/cookbook/lib/tutorial/imperative_modal_sheet.dart +++ b/example/lib/tutorial/imperative_modal_sheet.dart @@ -38,6 +38,11 @@ void _showModalSheet(BuildContext context) { final modalRoute = ModalSheetRoute( // Enable the swipe-to-dismiss behavior. swipeDismissible: true, + // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior. + swipeDismissSensitivity: const SwipeDismissSensitivity( + minFlingVelocityRatio: 2.0, + minDragDistance: 200.0, + ), builder: (context) => const _ExampleSheet(), ); @@ -62,7 +67,7 @@ class _ExampleSheet extends StatelessWidget { } }, child: DraggableSheet( - minExtent: const Extent.proportional(0.5), + minPosition: const SheetAnchor.proportional(0.5), child: Card( color: Theme.of(context).colorScheme.secondaryContainer, margin: EdgeInsets.zero, diff --git a/cookbook/lib/tutorial/imperative_navigation_sheet.dart b/example/lib/tutorial/imperative_navigation_sheet.dart similarity index 98% rename from cookbook/lib/tutorial/imperative_navigation_sheet.dart rename to example/lib/tutorial/imperative_navigation_sheet.dart index df9a733e..c2cfb5de 100644 --- a/cookbook/lib/tutorial/imperative_navigation_sheet.dart +++ b/example/lib/tutorial/imperative_navigation_sheet.dart @@ -22,7 +22,7 @@ class _ImperativeNavigationSheetExample extends StatelessWidget { } // NavigationSheet requires a special NavigatorObserver in order to -// smoothly change its extent during a route transition. +// smoothly change its position during a route transition. final _transitionObserver = NavigationSheetTransitionObserver(); class _ExampleSheet extends StatelessWidget { diff --git a/example/lib/tutorial/ios_style_declarative_modal_navigation_sheet.dart b/example/lib/tutorial/ios_style_declarative_modal_navigation_sheet.dart new file mode 100644 index 00000000..ad8dd430 --- /dev/null +++ b/example/lib/tutorial/ios_style_declarative_modal_navigation_sheet.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +/// Example code of iOS style modal `NavigationSheet` with go_router. +void main() { + runApp(const _App()); +} + +final transitionObserver = NavigationSheetTransitionObserver(); + +final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) { + return const _Home(); + }, + routes: [ + ShellRoute( + observers: [transitionObserver], + pageBuilder: (context, state, child) { + return CupertinoModalSheetPage( + key: state.pageKey, + child: _Modal(nestedNavigator: child), + ); + }, + routes: [ + GoRoute( + path: 'modal', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: Container(color: Colors.white), + ); + }, + ), + ], + ) + ]), + ], +); + +class _App extends StatelessWidget { + const _App(); + + @override + Widget build(BuildContext context) { + return MaterialApp.router(routerConfig: router); + } +} + +class _Home extends StatelessWidget { + const _Home(); + + @override + Widget build(BuildContext context) { + return CupertinoStackedTransition( + cornerRadius: Tween(begin: 0, end: 20), + child: Scaffold( + body: Center( + child: TextButton( + onPressed: () => context.go('/modal'), + child: const Text('Show Sheet'), + ), + ), + ), + ); + } +} + +class _Modal extends StatelessWidget { + const _Modal({ + required this.nestedNavigator, + }); + + final Widget nestedNavigator; + + @override + Widget build(BuildContext context) { + return NavigationSheet( + transitionObserver: transitionObserver, + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: nestedNavigator, + ), + ); + } +} diff --git a/cookbook/lib/tutorial/keyboard_dismiss_behavior.dart b/example/lib/tutorial/keyboard_dismiss_behavior.dart similarity index 86% rename from cookbook/lib/tutorial/keyboard_dismiss_behavior.dart rename to example/lib/tutorial/keyboard_dismiss_behavior.dart index c254a549..069732c9 100644 --- a/cookbook/lib/tutorial/keyboard_dismiss_behavior.dart +++ b/example/lib/tutorial/keyboard_dismiss_behavior.dart @@ -28,10 +28,6 @@ enum _KeyboardDismissBehaviorKind { onDragUp( 'onDragUp', 'Dismisses the keyboard only when the user drags the sheet upwards.', - ), - none( - 'Null', - 'Does not automatically dismiss the keyboard.', ); final String name; @@ -49,7 +45,7 @@ class _ExampleHome extends StatefulWidget { class _ExampleHomeState extends State<_ExampleHome> { _KeyboardDismissBehaviorKind selectedBehavior = - _KeyboardDismissBehaviorKind.none; + _KeyboardDismissBehaviorKind.onDrag; bool isContentScrollAware = false; bool isFullScreen = false; @@ -122,7 +118,6 @@ class _ExampleHomeState extends State<_ExampleHome> { void showExampleSheet(BuildContext context) { // This object determines when the sheet should dismisses the on-screen keyboard. final keyboardDismissBehavior = switch (selectedBehavior) { - _KeyboardDismissBehaviorKind.none => null, _KeyboardDismissBehaviorKind.onDrag => SheetKeyboardDismissBehavior.onDrag( isContentScrollAware: isContentScrollAware), @@ -173,25 +168,27 @@ class _ExampleSheet extends StatelessWidget { return SafeArea( bottom: false, - child: ScrollableSheet( - keyboardDismissBehavior: keyboardDismissBehavior, - child: SheetContentScaffold( - appBar: AppBar(), - body: body, - bottomBar: StickyBottomBarVisibility( - child: BottomAppBar( - child: Row( - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.menu), - ), - const Spacer(), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.more_vert), - ), - ], + child: SheetKeyboardDismissible( + dismissBehavior: keyboardDismissBehavior, + child: ScrollableSheet( + child: SheetContentScaffold( + appBar: AppBar(), + body: body, + bottomBar: StickyBottomBarVisibility( + child: BottomAppBar( + child: Row( + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu), + ), + const Spacer(), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.more_vert), + ), + ], + ), ), ), ), diff --git a/example/lib/tutorial/navigation_sheet_and_keyboard.dart b/example/lib/tutorial/navigation_sheet_and_keyboard.dart new file mode 100644 index 00000000..34adb4c2 --- /dev/null +++ b/example/lib/tutorial/navigation_sheet_and_keyboard.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(MaterialApp.router(routerConfig: _router)); +} + +final _transitionObserver = NavigationSheetTransitionObserver(); + +final _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const _Home(), + routes: [ + ShellRoute( + observers: [_transitionObserver], + pageBuilder: (context, state, child) { + return ModalSheetPage( + child: _MySheet( + transitionObserver: _transitionObserver, + navigator: child, + ), + ); + }, + routes: [ + GoRoute( + path: 'a', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _EditablePageContent( + height: 600, + nextLocation: '/a/b', + autofocus: true, + ), + ); + }, + routes: [ + GoRoute( + path: 'b', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _EditablePageContent( + height: 300, + nextLocation: '/a/b/c', + autofocus: true, + ), + ); + }, + routes: [ + GoRoute( + path: 'c', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _EditablePageContent( + nextLocation: '/', + height: double.infinity, + autofocus: false, + ), + ); + }, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], +); + +class _Home extends StatelessWidget { + const _Home(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Home'), + ), + body: Center( + child: ElevatedButton( + onPressed: () { + GoRouter.of(context).go('/a'); + }, + child: const Text('Open Sheet'), + ), + ), + ); + } +} + +class _MySheet extends StatelessWidget { + const _MySheet({ + required this.transitionObserver, + required this.navigator, + }); + + final NavigationSheetTransitionObserver transitionObserver; + final Widget navigator; + + @override + Widget build(BuildContext context) { + return NavigationSheet( + transitionObserver: transitionObserver, + child: ColoredBox( + color: Colors.white, + child: navigator, + ), + ); + } +} + +class _EditablePageContent extends StatelessWidget { + const _EditablePageContent({ + required this.nextLocation, + required this.autofocus, + required this.height, + }); + + final double height; + final String nextLocation; + final bool autofocus; + + @override + Widget build(BuildContext context) { + return SheetContentScaffold( + body: SizedBox( + height: height, + child: Column( + children: [ + TextField( + autofocus: autofocus, + ), + ElevatedButton( + onPressed: () => context.go(nextLocation), + child: const Text('Next'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Back'), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/tutorial/scrollable_pageview_sheet.dart b/example/lib/tutorial/scrollable_pageview_sheet.dart new file mode 100644 index 00000000..bb7c5d8f --- /dev/null +++ b/example/lib/tutorial/scrollable_pageview_sheet.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const _ScrollablePageViewSheetExample()); +} + +/// An example of [ScrollableSheet] + [PageView]. +class _ScrollablePageViewSheetExample extends StatelessWidget { + const _ScrollablePageViewSheetExample(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.push( + context, + ModalSheetRoute(builder: (_) => const _MySheet()), + ); + }, + child: const Text('Show Sheet'), + ); + }, + ), + ), + ), + ); + } +} + +class _MySheet extends StatelessWidget { + const _MySheet(); + + @override + Widget build(BuildContext context) { + return ScrollableSheet( + child: Material( + child: SizedBox( + height: 600, + child: PageView( + children: const [ + _PageViewItem(), + _PageViewItem(), + _PageViewItem(), + ], + ), + ), + ), + ); + } +} + +class _PageViewItem extends StatefulWidget { + const _PageViewItem(); + + @override + State<_PageViewItem> createState() => _PageViewItemState(); +} + +class _PageViewItemState extends State<_PageViewItem> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return ListView.builder( + itemCount: 100, + itemBuilder: (context, index) { + return ListTile( + onTap: () {}, + title: Text('Item $index'), + ); + }, + ); + } +} diff --git a/cookbook/lib/tutorial/scrollable_sheet.dart b/example/lib/tutorial/scrollable_sheet.dart similarity index 88% rename from cookbook/lib/tutorial/scrollable_sheet.dart rename to example/lib/tutorial/scrollable_sheet.dart index 757c7fd0..503f4e11 100644 --- a/cookbook/lib/tutorial/scrollable_sheet.dart +++ b/example/lib/tutorial/scrollable_sheet.dart @@ -45,14 +45,14 @@ class _MySheet extends StatelessWidget { child: buildSheetBackground(context, content), // Optional: Comment out the following lines to add multiple stop positions. // - // minExtent: const Extent.proportional(0.2), - // physics: StretchingSheetPhysics( + // minPosition: const SheetAnchor.proportional(0.2), + // physics: BouncingSheetPhysics( // parent: SnappingSheetPhysics( // snappingBehavior: SnapToNearest( // snapTo: [ - // const Extent.proportional(0.2), - // const Extent.proportional(0.5), - // const Extent.proportional(1), + // const SheetAnchor.proportional(0.2), + // const SheetAnchor.proportional(0.5), + // const SheetAnchor.proportional(1), // ], // ), // ), diff --git a/cookbook/lib/tutorial/sheet_content_scaffold.dart b/example/lib/tutorial/sheet_content_scaffold.dart similarity index 88% rename from cookbook/lib/tutorial/sheet_content_scaffold.dart rename to example/lib/tutorial/sheet_content_scaffold.dart index bc34c55c..1e8f55a8 100644 --- a/cookbook/lib/tutorial/sheet_content_scaffold.dart +++ b/example/lib/tutorial/sheet_content_scaffold.dart @@ -44,19 +44,20 @@ class _ExampleSheet extends StatelessWidget { getIsVisible: (metrics) { return metrics.viewportInsets.bottom == 0 && metrics.pixels > - const Extent.proportional(0.5).resolve(metrics.contentSize); + const SheetAnchor.proportional(0.5) + .resolve(metrics.contentSize); }, child: buildBottomBar(), ), ); - final physics = StretchingSheetPhysics( + const physics = BouncingSheetPhysics( parent: SnappingSheetPhysics( - snappingBehavior: SnapToNearest( - snapTo: [ - const Extent.proportional(0.2), - const Extent.proportional(0.5), - const Extent.proportional(1), + behavior: SnapToNearest( + anchors: [ + SheetAnchor.proportional(0.2), + SheetAnchor.proportional(0.5), + SheetAnchor.proportional(1), ], ), ), @@ -64,7 +65,7 @@ class _ExampleSheet extends StatelessWidget { return DraggableSheet( physics: physics, - minExtent: const Extent.pixels(0), + minPosition: const SheetAnchor.pixels(0), child: Card( clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, diff --git a/cookbook/lib/tutorial/sheet_controller.dart b/example/lib/tutorial/sheet_controller.dart similarity index 88% rename from cookbook/lib/tutorial/sheet_controller.dart rename to example/lib/tutorial/sheet_controller.dart index c867e4c7..3840bec1 100644 --- a/cookbook/lib/tutorial/sheet_controller.dart +++ b/example/lib/tutorial/sheet_controller.dart @@ -47,12 +47,12 @@ class _ExampleHomeState extends State<_ExampleHome> { child: Align( alignment: Alignment.topCenter, // Like ScrollController for scrollable widgets, - // SheetController can be used to observe changes in the sheet extent. + // SheetController can be used to observe changes in the sheet position. child: ValueListenableBuilder( valueListenable: controller, - builder: (context, metrics, child) { + builder: (context, pixels, child) { return Text( - 'Extent: ${metrics.maybePixels?.toStringAsFixed(1)}', + 'Position: ${pixels?.toStringAsFixed(1)}', style: Theme.of(context).textTheme.displaySmall, ); }, @@ -69,8 +69,8 @@ class _ExampleHomeState extends State<_ExampleHome> { foregroundColor: Theme.of(context).colorScheme.onPrimary, child: const Icon(Icons.arrow_downward_rounded), onPressed: () { - // SheetController can also be used to animate the sheet extent. - controller.animateTo(const Extent.proportional(0.5)); + // SheetController can also be used to animate the sheet position. + controller.animateTo(const SheetAnchor.proportional(0.5)); }, ), ); @@ -88,8 +88,8 @@ class _ExampleSheet extends StatelessWidget { Widget build(BuildContext context) { return DraggableSheet( controller: controller, - minExtent: const Extent.proportional(0.5), - physics: const StretchingSheetPhysics( + minPosition: const SheetAnchor.proportional(0.5), + physics: const BouncingSheetPhysics( parent: SnappingSheetPhysics(), ), child: Card( diff --git a/cookbook/lib/tutorial/sheet_draggable.dart b/example/lib/tutorial/sheet_draggable.dart similarity index 93% rename from cookbook/lib/tutorial/sheet_draggable.dart rename to example/lib/tutorial/sheet_draggable.dart index 62e46af7..d061d963 100644 --- a/cookbook/lib/tutorial/sheet_draggable.dart +++ b/example/lib/tutorial/sheet_draggable.dart @@ -65,8 +65,8 @@ class _ExampleSheet extends StatelessWidget { ], ); - const minExtent = Extent.proportional(0.5); - const physics = StretchingSheetPhysics( + const minPosition = SheetAnchor.proportional(0.5); + const physics = BouncingSheetPhysics( parent: SnappingSheetPhysics(), ); @@ -74,8 +74,8 @@ class _ExampleSheet extends StatelessWidget { bottom: false, child: ScrollableSheet( physics: physics, - minExtent: minExtent, - initialExtent: minExtent, + minPosition: minPosition, + initialPosition: minPosition, child: Card( margin: EdgeInsets.zero, color: Theme.of(context).colorScheme.secondaryContainer, diff --git a/cookbook/lib/tutorial/sheet_physics.dart b/example/lib/tutorial/sheet_physics.dart similarity index 76% rename from cookbook/lib/tutorial/sheet_physics.dart rename to example/lib/tutorial/sheet_physics.dart index 69f346e4..9965fef3 100644 --- a/cookbook/lib/tutorial/sheet_physics.dart +++ b/example/lib/tutorial/sheet_physics.dart @@ -18,9 +18,9 @@ class _SheetPhysicsExample extends StatelessWidget { enum _PhysicsKind { clamping('Clamping'), - stretching('Stretching'), + bouncing('Bouncing'), clampingSnapping('Clamping + Snapping'), - stretchingSnapping('Stretching + Snapping'); + bouncingSnapping('Bouncing + Snapping'); final String name; @@ -35,7 +35,7 @@ class _ExampleHome extends StatefulWidget { } class _ExampleHomeState extends State<_ExampleHome> { - _PhysicsKind selectedPhysics = _PhysicsKind.stretchingSnapping; + _PhysicsKind selectedPhysics = _PhysicsKind.bouncingSnapping; @override Widget build(BuildContext context) { @@ -84,46 +84,48 @@ class _MySheet extends StatelessWidget { SheetPhysics createPhysics(_PhysicsKind kind) { // With this configuration, the sheet will snap to: - // - the extent at which ony (_halfwayFraction * 100)% of the content is visible, or - // - the extent at which the entire content is visible. - // Note that the "extent" is the visible height of the sheet. - final snappingPhysics = SnappingSheetPhysics( - snappingBehavior: SnapToNearest( - snapTo: [ - const Extent.proportional(_halfwayFraction), - const Extent.proportional(1), + // - the position at which ony (_halfwayFraction * 100)% of the content is visible, or + // - the position at which the entire content is visible. + // Note that the "position" is the visible height of the sheet. + const snappingPhysics = SnappingSheetPhysics( + behavior: SnapToNearest( + anchors: [ + SheetAnchor.proportional(_halfwayFraction), + SheetAnchor.proportional(1), ], ), // Tips: The above configuration can be replaced with a 'SnapToNearestEdge', - // which will snap to either the 'minExtent' or 'maxExtent' of the sheet: + // which will snap to either the 'minPosition' or 'maxPosition' of the sheet: // snappingBehavior: const SnapToNearestEdge(), ); return switch (kind) { _PhysicsKind.clamping => const ClampingSheetPhysics(), - _PhysicsKind.stretching => const StretchingSheetPhysics(), + _PhysicsKind.bouncing => const BouncingSheetPhysics(), _PhysicsKind.clampingSnapping => // Use 'parent' to combine multiple physics behaviors. - ClampingSheetPhysics(parent: snappingPhysics), - _PhysicsKind.stretchingSnapping => - StretchingSheetPhysics(parent: snappingPhysics), + const ClampingSheetPhysics(parent: snappingPhysics), + _PhysicsKind.bouncingSnapping => + const BouncingSheetPhysics(parent: snappingPhysics), }; } @override Widget build(BuildContext context) { return DraggableSheet( - // The 'minExtent' and 'maxExtent' properties determine - // how far the sheet can be dragged. Note that "extent" + // The 'minPosition' and 'maxPosition' properties determine + // how far the sheet can be dragged. Note that "position" // refers to the visible height of the sheet. For example, // the configuration below ensures that the sheet is fully visible // at first and can then be dragged down to (_halfwayFraction * 100)% // of the sheet height at minimum. - minExtent: const Extent.proportional(_halfwayFraction), - maxExtent: const Extent.proportional(1), // Default - initialExtent: const Extent.proportional(1), // Default + minPosition: const SheetAnchor.proportional(_halfwayFraction), + maxPosition: const SheetAnchor.proportional(1), + // Default + initialPosition: const SheetAnchor.proportional(1), + // Default // 'physics' determines how the sheet will behave when the user reaches - // the maximum or minimum extent, or when the user stops dragging. + // the maximum or minimum position, or when the user stops dragging. physics: createPhysics(physicsKind), child: buildContent(context), ); diff --git a/example/lib/tutorial/textfield_with_multiple_stops.dart b/example/lib/tutorial/textfield_with_multiple_stops.dart new file mode 100644 index 00000000..08bd265b --- /dev/null +++ b/example/lib/tutorial/textfield_with_multiple_stops.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const TextFieldWithMultipleStops()); +} + +class TextFieldWithMultipleStops extends StatelessWidget { + const TextFieldWithMultipleStops({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Stack( + children: [ + const Scaffold(), + ScrollableSheet( + initialPosition: const SheetAnchor.proportional(0.7), + minPosition: const SheetAnchor.proportional(0.4), + physics: const BouncingSheetPhysics( + parent: SnappingSheetPhysics( + behavior: SnapToNearest( + anchors: [ + SheetAnchor.proportional(0.4), + SheetAnchor.proportional(0.7), + SheetAnchor.proportional(1), + ], + ), + ), + ), + child: SheetContentScaffold( + primary: true, + backgroundColor: Colors.grey, + appBar: AppBar( + backgroundColor: Colors.grey, + title: const Text('Sheet with a TextField'), + leading: IconButton( + onPressed: () => primaryFocus?.unfocus(), + icon: const Icon(Icons.keyboard_hide), + ), + ), + body: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: const SingleChildScrollView( + child: TextField(maxLines: null), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 00000000..4b9761c5 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,273 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + faker: + dependency: "direct main" + description: + name: faker + sha256: "746e59f91d8b06a389e74cf76e909a05ed69c12691768e2f93557fdf29200fd0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c5fa45fa502ee880839e3b2152d987c44abae26d064a2376d4aad434cf0f7b15 + url: "https://pub.dev" + source: hosted + version: "12.1.3" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + smooth_sheets: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.10.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" +sdks: + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/cookbook/pubspec.yaml b/example/pubspec.yaml similarity index 94% rename from cookbook/pubspec.yaml rename to example/pubspec.yaml index ae76337d..113f14c9 100644 --- a/cookbook/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: go_router: ^12.1.3 smooth_sheets: - path: ../package + path: ../ dev_dependencies: flutter_test: diff --git a/lib/.idea/lib.iml b/lib/.idea/lib.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/lib/.idea/lib.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/lib/.idea/misc.xml b/lib/.idea/misc.xml new file mode 100644 index 00000000..a6e1098c --- /dev/null +++ b/lib/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/lib/.idea/modules.xml b/lib/.idea/modules.xml new file mode 100644 index 00000000..b0c4ff69 --- /dev/null +++ b/lib/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/lib/.idea/other.xml b/lib/.idea/other.xml new file mode 100644 index 00000000..49481ad4 --- /dev/null +++ b/lib/.idea/other.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/lib/.idea/vcs.xml b/lib/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/lib/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/lib/.idea/workspace.xml b/lib/.idea/workspace.xml new file mode 100644 index 00000000..da06c275 --- /dev/null +++ b/lib/.idea/workspace.xml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1730740540265 + + + + \ No newline at end of file diff --git a/package/lib/smooth_sheets.dart b/lib/smooth_sheets.dart similarity index 94% rename from package/lib/smooth_sheets.dart rename to lib/smooth_sheets.dart index a8e7e4fc..abbc8c9f 100644 --- a/package/lib/smooth_sheets.dart +++ b/lib/smooth_sheets.dart @@ -1,7 +1,7 @@ /// Comprehensive bottom sheet library supporting imperative and declarative /// navigation APIs, nested navigation, persistent and modal styles (including /// the iOS flavor), and more. -library smooth_sheets; +library; export 'src/draggable/draggable.dart'; export 'src/foundation/foundation.dart'; diff --git a/package/lib/src/draggable/draggable.dart b/lib/src/draggable/draggable.dart similarity index 100% rename from package/lib/src/draggable/draggable.dart rename to lib/src/draggable/draggable.dart diff --git a/lib/src/draggable/draggable_sheet.dart b/lib/src/draggable/draggable_sheet.dart new file mode 100644 index 00000000..f10de151 --- /dev/null +++ b/lib/src/draggable/draggable_sheet.dart @@ -0,0 +1,98 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_controller.dart'; +import '../foundation/sheet_gesture_tamperer.dart'; +import '../foundation/sheet_physics.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_theme.dart'; +import '../foundation/sheet_viewport.dart'; +import '../scrollable/scrollable_sheet.dart'; +import 'draggable_sheet_position_scope.dart'; +import 'sheet_draggable.dart'; + +/// A sheet that can be dragged. +/// +/// Note that this widget does not work with scrollable widgets. +/// Instead, use [ScrollableSheet] for this usecase. +class DraggableSheet extends StatefulWidget { + /// Creates a sheet that can be dragged. + /// + /// The maximum height will be equal to the [child]'s height. + /// + /// The [physics] determines how the sheet will behave when over-dragged + /// or under-dragged, or when the user stops dragging. + /// + /// The [hitTestBehavior] defaults to [HitTestBehavior.translucent]. + /// + /// See also: + /// - [A tutorial](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/draggable_sheet.dart), + /// minimal code to use a draggable sheet. + const DraggableSheet({ + super.key, + this.hitTestBehavior = HitTestBehavior.translucent, + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), + this.physics, + required this.child, + this.controller, + }); + + final SheetAnchor initialPosition; + + /// {@macro SheetPositionConfig.minPosition} + final SheetAnchor minPosition; + + /// {@macro SheetPositionConfig.maxPosition} + final SheetAnchor maxPosition; + + /// {@macro SheetPositionConfig.physics} + final SheetPhysics? physics; + + /// An object that can be used to control and observe the sheet height. + final SheetController? controller; + + /// The content of the sheet. + final Widget child; + + /// How to behave during hit testing. + /// + /// This value will be passed to the constructor of internal [SheetDraggable]. + final HitTestBehavior hitTestBehavior; + + @override + State createState() => _DraggableSheetState(); +} + +class _DraggableSheetState extends State + with TickerProviderStateMixin, SheetContextStateMixin { + @override + Widget build(BuildContext context) { + final theme = SheetTheme.maybeOf(context); + final physics = widget.physics ?? theme?.physics ?? kDefaultSheetPhysics; + final gestureTamper = SheetGestureProxy.maybeOf(context); + final controller = + widget.controller ?? SheetControllerScope.maybeOf(context); + + return DraggableSheetPositionScope( + context: this, + controller: controller, + initialPosition: widget.initialPosition, + minPosition: widget.minPosition, + maxPosition: widget.maxPosition, + physics: physics, + gestureTamperer: gestureTamper, + debugLabel: kDebugMode ? 'DraggableSheet' : null, + child: SheetViewport( + child: SheetContentViewport( + child: SheetDraggable( + behavior: widget.hitTestBehavior, + child: widget.child, + ), + ), + ), + ); + } +} diff --git a/lib/src/draggable/draggable_sheet_position.dart b/lib/src/draggable/draggable_sheet_position.dart new file mode 100644 index 00000000..41439dba --- /dev/null +++ b/lib/src/draggable/draggable_sheet_position.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../foundation/sheet_position.dart'; + +@internal +class DraggableSheetPosition extends SheetPosition { + DraggableSheetPosition({ + required super.context, + required super.minPosition, + required super.maxPosition, + required this.initialPosition, + required super.physics, + super.gestureTamperer, + super.debugLabel, + }); + + /// {@template DraggableSheetPosition.initialPosition} + /// The initial position of the sheet. + /// {@endtemplate} + final SheetAnchor initialPosition; + + @override + void applyNewContentSize(Size contentSize) { + super.applyNewContentSize(contentSize); + if (maybePixels == null) { + setPixels(initialPosition.resolve(contentSize)); + } + } +} diff --git a/lib/src/draggable/draggable_sheet_position_scope.dart b/lib/src/draggable/draggable_sheet_position_scope.dart new file mode 100644 index 00000000..04c24445 --- /dev/null +++ b/lib/src/draggable/draggable_sheet_position_scope.dart @@ -0,0 +1,57 @@ +import 'package:meta/meta.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; +import 'draggable_sheet_position.dart'; + +@internal +class DraggableSheetPositionScope extends SheetPositionScope { + const DraggableSheetPositionScope({ + super.key, + super.controller, + super.isPrimary, + required super.context, + required this.initialPosition, + required super.minPosition, + required super.maxPosition, + required super.physics, + super.gestureTamperer, + this.debugLabel, + required super.child, + }); + + /// {@macro DraggableSheetPosition.initialPosition} + final SheetAnchor initialPosition; + + /// {@macro SheetPosition.debugLabel} + final String? debugLabel; + + @override + SheetPositionScopeState createState() { + return _DraggableSheetPositionScopeState(); + } +} + +class _DraggableSheetPositionScopeState extends SheetPositionScopeState< + DraggableSheetPosition, DraggableSheetPositionScope> { + @override + bool shouldRebuildPosition(DraggableSheetPosition oldPosition) { + return widget.initialPosition != oldPosition.initialPosition || + widget.debugLabel != oldPosition.debugLabel || + super.shouldRebuildPosition(oldPosition); + } + + @override + DraggableSheetPosition buildPosition(SheetContext context) { + return DraggableSheetPosition( + context: context, + initialPosition: widget.initialPosition, + minPosition: widget.minPosition, + maxPosition: widget.maxPosition, + physics: widget.physics, + gestureTamperer: widget.gestureTamperer, + debugLabel: widget.debugLabel, + ); + } +} diff --git a/package/lib/src/draggable/sheet_draggable.dart b/lib/src/draggable/sheet_draggable.dart similarity index 91% rename from package/lib/src/draggable/sheet_draggable.dart rename to lib/src/draggable/sheet_draggable.dart index d02f915f..9d326bcd 100644 --- a/package/lib/src/draggable/sheet_draggable.dart +++ b/lib/src/draggable/sheet_draggable.dart @@ -2,7 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import '../foundation/sheet_extent.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; import '../scrollable/scrollable_sheet.dart'; import 'draggable_sheet.dart'; @@ -40,18 +41,18 @@ class SheetDraggable extends StatefulWidget { } class _SheetDraggableState extends State { - SheetExtent? _extent; + SheetPosition? _position; Drag? _currentDrag; @override void didChangeDependencies() { super.didChangeDependencies(); - _extent = SheetExtentScope.maybeOf(context); + _position = SheetPositionScope.maybeOf(context); } @override void dispose() { - _extent = null; + _position = null; _disposeDrag(); super.dispose(); } @@ -62,7 +63,7 @@ class _SheetDraggableState extends State { void _handleDragStart(DragStartDetails details) { assert(_currentDrag == null); - _currentDrag = _extent?.drag(details, _disposeDrag); + _currentDrag = _position?.drag(details, _disposeDrag); } void _handleDragUpdate(DragUpdateDetails details) { diff --git a/package/lib/src/foundation/foundation.dart b/lib/src/foundation/foundation.dart similarity index 75% rename from package/lib/src/foundation/foundation.dart rename to lib/src/foundation/foundation.dart index 20070419..f59bb803 100644 --- a/package/lib/src/foundation/foundation.dart +++ b/lib/src/foundation/foundation.dart @@ -1,4 +1,3 @@ -export 'extent_driven_animation.dart' show ExtentDrivenAnimation; export 'keyboard_dismissible.dart' show DragDownSheetKeyboardDismissBehavior, @@ -18,14 +17,14 @@ export 'sheet_content_scaffold.dart' export 'sheet_controller.dart' show DefaultSheetController, SheetController; export 'sheet_drag.dart' show + SheetDragCancelDetails, SheetDragDetails, SheetDragEndDetails, SheetDragStartDetails, SheetDragUpdateDetails; -export 'sheet_extent.dart' - show Extent, FixedExtent, ProportionalExtent, SheetMetrics; export 'sheet_notification.dart' show + SheetDragCancelNotification, SheetDragEndNotification, SheetDragStartNotification, SheetDragUpdateNotification, @@ -34,16 +33,26 @@ export 'sheet_notification.dart' SheetUpdateNotification; export 'sheet_physics.dart' show + BouncingBehavior, + BouncingSheetPhysics, ClampingSheetPhysics, - InterpolationSimulation, + DirectionAwareBouncingBehavior, + FixedBouncingBehavior, SheetPhysics, SheetPhysicsMixin, SnapToNearest, SnapToNearestEdge, SnappingSheetBehavior, SnappingSheetPhysics, - StretchingSheetPhysics, kDefaultSheetPhysics, kDefaultSheetSpring; +export 'sheet_position.dart' + show + FixedSheetAnchor, + ProportionalSheetAnchor, + SheetAnchor, + SheetMetrics, + SheetMetricsSnapshot; +export 'sheet_position_driven_animation.dart' show SheetPositionDrivenAnimation; export 'sheet_status.dart' show SheetStatus; export 'sheet_theme.dart' show SheetTheme, SheetThemeData; diff --git a/package/lib/src/foundation/keyboard_dismissible.dart b/lib/src/foundation/keyboard_dismissible.dart similarity index 76% rename from package/lib/src/foundation/keyboard_dismissible.dart rename to lib/src/foundation/keyboard_dismissible.dart index 05dc85e6..2ab5e9fb 100644 --- a/package/lib/src/foundation/keyboard_dismissible.dart +++ b/lib/src/foundation/keyboard_dismissible.dart @@ -1,18 +1,31 @@ import 'package:flutter/widgets.dart'; -import '../draggable/draggable_sheet.dart'; + import 'sheet_notification.dart'; +import 'sheet_theme.dart'; /// A widget that dismisses the on-screen keyboard when the user /// drags the sheet below this widget. /// -/// It is rarely used directly since the sheets internally have this widget -/// and expose a slot for a [SheetKeyboardDismissBehavior], which is directly -/// passed to this widget. +/// The following snippet is an example of a sheet in which the on-screen +/// keyboard is dismissed when the user drags the sheet downwards: +/// +/// ```dart +/// return SheetKeyboardDismissible( +/// dismissBehavior: const SheetKeyboardDismissBehavior.onDragDown(), +/// child: DraggableSheet( +/// child: Container( +/// color: Colors.white, +/// width: double.infinity, +/// height: 500, +/// ), +/// ), +/// ); +/// ``` /// /// See also: -/// - [DraggableSheet.keyboardDismissBehavior], which is the slot for -/// a custom [SheetKeyboardDismissBehavior]. -class SheetKeyboardDismissible extends StatelessWidget { +/// - [SheetKeyboardDismissBehavior], which determines when the on-screen +/// keyboard should be dismissed. +class SheetKeyboardDismissible extends StatefulWidget { /// Creates a widget that dismisses the on-screen keyboard when the user /// drags the sheet below this widget. const SheetKeyboardDismissible({ @@ -22,11 +35,27 @@ class SheetKeyboardDismissible extends StatelessWidget { }); /// Determines when the on-screen keyboard should be dismissed. - final SheetKeyboardDismissBehavior dismissBehavior; + /// + /// If null, [SheetThemeData.keyboardDismissBehavior] obtained by + /// [SheetTheme.maybeOf] will be used. If that is also null, + /// [DragSheetKeyboardDismissBehavior] with `isContentScrollAware` set to + /// `false` will be used as a fallback. + final SheetKeyboardDismissBehavior? dismissBehavior; /// The widget below this widget in the tree. final Widget child; + @override + State createState() => + _SheetKeyboardDismissibleState(); +} + +class _SheetKeyboardDismissibleState extends State { + SheetKeyboardDismissBehavior get _dismissBehavior => + widget.dismissBehavior ?? + SheetTheme.maybeOf(context)?.keyboardDismissBehavior ?? + const DragSheetKeyboardDismissBehavior(); + @override Widget build(BuildContext context) { Widget result = NotificationListener( @@ -37,22 +66,22 @@ class SheetKeyboardDismissible extends StatelessWidget { }; if (primaryFocus?.hasFocus == true && - dismissBehavior.shouldDismissKeyboard(delta)) { + _dismissBehavior.shouldDismissKeyboard(delta)) { primaryFocus!.unfocus(); } return false; }, - child: child, + child: widget.child, ); - if (dismissBehavior.isContentScrollAware) { + if (_dismissBehavior.isContentScrollAware) { result = NotificationListener( onNotification: (notification) { final dragDelta = notification.dragDetails?.delta.dy; if (notification.depth == 0 && dragDelta != null && primaryFocus?.hasFocus == true && - dismissBehavior.shouldDismissKeyboard(-1 * dragDelta)) { + _dismissBehavior.shouldDismissKeyboard(-1 * dragDelta)) { primaryFocus!.unfocus(); } return false; diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart new file mode 100644 index 00000000..a3b846a4 --- /dev/null +++ b/lib/src/foundation/sheet_activity.dart @@ -0,0 +1,622 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import 'sheet_drag.dart'; +import 'sheet_physics.dart'; +import 'sheet_position.dart'; +import 'sheet_status.dart'; + +@internal +@optionalTypeArgs +abstract class SheetActivity { + bool _disposed = false; + + bool get disposed { + assert(!_mounted || !_disposed); + return _disposed; + } + + bool _mounted = false; + + bool get mounted { + assert(!_mounted || !_disposed); + return _mounted; + } + + T? _owner; + + T get owner { + assert(debugAssertMounted()); + return _owner!; + } + + double get velocity => 0.0; + + SheetStatus get status; + + @mustCallSuper + void init(T owner) { + assert(_owner == null); + assert(!_mounted); + assert(!_disposed); + + _owner = owner; + _mounted = true; + } + + @mustCallSuper + void updateOwner(T owner) { + _owner = owner; + } + + void dispose() { + _mounted = false; + _disposed = true; + } + + /// Whether the sheet should ignore pointer events while performing + /// this activity. + bool get shouldIgnorePointer => false; + + bool isCompatibleWith(SheetPosition newOwner) => newOwner is T; + + void didChangeContentSize(Size? oldSize) {} + + void didChangeViewportDimensions(Size? oldSize, EdgeInsets? oldInsets) {} + + void didChangeBoundaryConstraints( + SheetAnchor? oldMinPosition, + SheetAnchor? oldMaxPosition, + ) {} + + /// Called when all relevant metrics of the sheet are finalized + /// for the current frame. + /// + /// The [oldContentSize], [oldViewportSize], and [oldViewportInsets] will be + /// `null` if the [SheetMetrics.contentSize], [SheetMetrics.viewportSize], and + /// [SheetMetrics.viewportInsets] have not changed since the previous frame. + /// + /// Since this is called after the layout phase and before the painting phase + /// of the sheet, it is safe to update [SheetMetrics.pixels] to reflect the + /// latest metrics. + /// + /// By default, this method updates [SheetMetrics.pixels] to maintain the + /// visual position of the sheet when the viewport insets change, typically + /// due to the appearance or disappearance of the on-screen keyboard. + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + } + + @protected + bool debugAssertMounted() { + assert(() { + if (!mounted) { + throw FlutterError( + 'A $runtimeType was used after being disposed, or ' + 'before init() was called. Once you have called dispose() ' + 'on a $runtimeType, it can no longer be used.', + ); + } + return true; + }()); + return true; + } + + @protected + bool debugAssertNotDisposed() { + assert(() { + if (disposed) { + throw FlutterError( + 'A $runtimeType was used after being disposed. Once you have ' + 'called dispose() on a $runtimeType, it can no longer be used.', + ); + } + return true; + }()); + return true; + } +} + +/// An activity that animates the [SheetPosition]'s `pixels` to a destination +/// position determined by [destination], using the specified [curve] and +/// [duration]. +/// +/// This activity accepts the destination position as an [SheetAnchor], allowing +/// the concrete end position (in pixels) to be updated during the animation +/// in response to viewport changes, such as the appearance of the on-screen +/// keyboard. +/// +/// When the bottom viewport inset changes, typically due to the appearance +/// or disappearance of the on-screen keyboard, this activity updates the +/// sheet position to maintain its visual position unchanged. If the +/// end position changes, it starts a [SettlingSheetActivity] for the +/// remaining duration to ensure the animation duration remains consistent. +@internal +class AnimatedSheetActivity extends SheetActivity + with ControlledSheetActivityMixin { + AnimatedSheetActivity({ + required this.destination, + required this.duration, + required this.curve, + }) : assert(duration > Duration.zero); + + final SheetAnchor destination; + final Duration duration; + final Curve curve; + + late final double _startPixels; + late final double _endPixels; + + @override + void init(SheetPosition delegate) { + super.init(delegate); + _startPixels = owner.pixels; + _endPixels = destination.resolve(owner.contentSize); + } + + @override + AnimationController createAnimationController() { + return AnimationController.unbounded(vsync: owner.context.vsync); + } + + @override + TickerFuture onAnimationStart() { + return controller.animateTo( + 1.0, + duration: duration, + curve: curve, + ); + } + + @override + void onAnimationTick() { + final progress = curve.transform(controller.value); + owner + ..setPixels(lerpDouble(_startPixels, _endPixels, progress)!) + ..didUpdateMetrics(); + } + + @override + void onAnimationEnd() { + owner.goBallistic(0); + } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + final newEndPixels = destination.resolve(owner.contentSize); + if (newEndPixels != _endPixels) { + final remainingDuration = + duration - (controller.lastElapsedDuration ?? Duration.zero); + owner.settleTo(destination, remainingDuration); + } + } +} + +@internal +class BallisticSheetActivity extends SheetActivity + with ControlledSheetActivityMixin { + BallisticSheetActivity({ + required this.simulation, + }); + + final Simulation simulation; + + @override + AnimationController createAnimationController() { + return AnimationController.unbounded(vsync: owner.context.vsync); + } + + @override + TickerFuture onAnimationStart() { + return controller.animateWith(simulation); + } + + @override + void onAnimationTick() { + if (mounted) { + owner + ..setPixels(controller.value) + ..didUpdateMetrics(); + } + } + + @override + void onAnimationEnd() { + owner.goBallistic(0); + } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldContentSize == null && + oldViewportSize == null && + oldViewportInsets == null) { + return; + } + + final oldMetrics = owner.copyWith( + contentSize: oldContentSize, + viewportSize: oldViewportSize, + viewportInsets: oldViewportInsets, + ); + final destination = owner.physics.findSettledPosition(velocity, oldMetrics); + + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + + final endPixels = destination.resolve(owner.contentSize); + if (endPixels == owner.pixels) { + return; + } + + const maxSettlingDuration = 150; // milliseconds + final distance = (endPixels - owner.pixels).abs(); + final velocityNorm = velocity.abs(); + final estimatedSettlingDuration = velocityNorm > 0 + ? distance / velocityNorm * Duration.millisecondsPerSecond + : double.infinity; + + owner.settleTo( + destination, + estimatedSettlingDuration > maxSettlingDuration + ? const Duration(milliseconds: maxSettlingDuration) + : Duration(milliseconds: estimatedSettlingDuration.round()), + ); + } +} + +/// A [SheetActivity] that performs a settling motion in response to changes +/// in the viewport dimensions or content size. +/// +/// A [SheetPosition] may start this activity when the viewport insets change +/// during an animation, typically due to the appearance or disappearance of +/// the on-screen keyboard, or when the content size changes (e.g., due to +/// entering a new line of text in a text field). +/// +/// This activity animates the sheet position to the [destination] with a +/// constant [velocity] until the destination is reached. Optionally, the +/// animation [duration] can be specified to explicitly control the time it +/// takes to reach the [destination]. In this case, the [velocity] is determined +/// based on the distance to the [destination] and the specified [duration]. +/// +/// When the concrete value of the [destination] changes due to viewport +/// metrics or content size changes, and the [duration] is specified, +/// the [velocity] is recalculated to ensure the animation duration remains +/// consistent. +@internal +class SettlingSheetActivity extends SheetActivity { + /// Creates a settling activity that animates the sheet position to the + /// [destination] with a constant [velocity]. + SettlingSheetActivity({ + required this.destination, + required double velocity, + }) : assert(velocity > 0), + _velocity = velocity, + duration = null; + + /// Creates a settling activity that animates the sheet position to the + /// [destination] over the specified [duration]. + SettlingSheetActivity.withDuration( + Duration this.duration, { + required this.destination, + }) : assert(duration > Duration.zero); + + /// The amount of time the animation should take to reach the destination. + /// + /// If `null`, the animation lasts until the destination is reached + /// or this activity is disposed. + final Duration? duration; + + /// The destination position to which the sheet should settle. + final SheetAnchor destination; + + late final Ticker _ticker; + + /// The amount of time that has passed between the time the animation + /// started and the most recent tick of the animation. + var _elapsedDuration = Duration.zero; + + @override + double get velocity => _velocity; + late double _velocity; + + @override + SheetStatus get status => SheetStatus.animating; + + @override + void init(SheetPosition owner) { + super.init(owner); + _ticker = owner.context.vsync.createTicker(_tick)..start(); + _invalidateVelocity(); + } + + /// Updates the sheet position toward the destination based on the current + /// [_velocity] and the time elapsed since the last frame. + /// + /// If the destination is reached, a ballistic activity is started with + /// zero velocity to ensure consistency between the settled position + /// and the current [SheetPhysics]. + void _tick(Duration elapsedDuration) { + final elapsedFrameTime = + (elapsedDuration - _elapsedDuration).inMicroseconds / + Duration.microsecondsPerSecond; + final destination = this.destination.resolve(owner.contentSize); + final pixels = owner.pixels; + final newPixels = destination > pixels + ? min(destination, pixels + velocity * elapsedFrameTime) + : max(destination, pixels - velocity * elapsedFrameTime); + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + + _elapsedDuration = elapsedDuration; + + if (newPixels == destination) { + owner.goIdle(); + } + } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + + _invalidateVelocity(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + /// Updates [_velocity] based on the remaining time and distance to the + /// destination position. + /// + /// Make sure to call this method on initialization and whenever the + /// destination changes due to the viewport size or content size changing. + /// + /// If the animation [duration] is not specified, this method preserves the + /// current velocity. + void _invalidateVelocity() { + if (duration case final duration?) { + final remainingSeconds = (duration - _elapsedDuration).inMicroseconds / + Duration.microsecondsPerSecond; + final destination = this.destination.resolve(owner.contentSize); + final pixels = owner.pixels; + _velocity = remainingSeconds > 0 + ? (destination - pixels).abs() / remainingSeconds + : (destination - pixels).abs(); + } + } +} + +@internal +class IdleSheetActivity extends SheetActivity { + @override + SheetStatus get status => SheetStatus.stable; + + /// Updates [SheetMetrics.pixels] to maintain the current [SheetAnchor], which + /// is determined by [SheetPhysics.findSettledPosition] using the metrics of + /// the previous frame. + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldContentSize == null && + oldViewportSize == null && + oldViewportInsets == null) { + return; + } + + final oldMetrics = owner.copyWith( + contentSize: oldContentSize, + viewportSize: oldViewportSize, + viewportInsets: oldViewportInsets, + ); + final prevDetent = owner.physics.findSettledPosition(0, oldMetrics); + final newPixels = prevDetent.resolve(owner.contentSize); + + if (newPixels == owner.pixels) { + return; + } else if (oldViewportInsets != null && + oldViewportInsets.bottom != owner.viewportInsets.bottom) { + // TODO: Is it possible to remove this assumption? + // We currently assume that when the bottom viewport inset changes, + // it is due to the appearance or disappearance of the keyboard, + // and that this change will gradually occur over several frames, + // likely due to animation. + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + return; + } + + const minAnimationDuration = Duration(milliseconds: 150); + const meanAnimationVelocity = 300 / 1000; // pixels per millisecond + final distance = (newPixels - owner.pixels).abs(); + final estimatedDuration = Duration( + milliseconds: (distance / meanAnimationVelocity).round(), + ); + if (estimatedDuration >= minAnimationDuration) { + owner.animateTo( + prevDetent, + duration: estimatedDuration, + curve: Curves.easeInOut, + ); + } else { + // The destination is close enough to the current position, + // so we immediately snap to it without animation. + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + } + } +} + +@internal +class DragSheetActivity extends SheetActivity + with UserControlledSheetActivityMixin + implements SheetDragControllerTarget { + DragSheetActivity(); + + @override + VerticalDirection get dragAxisDirection => VerticalDirection.up; + + @override + Offset computeMinPotentialDeltaConsumption(Offset delta) { + switch (delta.dy) { + case > 0: + final draggableDistance = max(0.0, owner.maxPixels - owner.pixels); + return Offset(delta.dx, min(draggableDistance, delta.dy)); + + case < 0: + final draggableDistance = max(0.0, owner.pixels - owner.minPixels); + return Offset(delta.dx, max(-1 * draggableDistance, delta.dy)); + + case _: + return delta; + } + } + + @override + void onDragUpdate(SheetDragUpdateDetails details) { + final physicsAppliedDelta = + owner.physics.applyPhysicsToOffset(details.deltaY, owner); + if (physicsAppliedDelta != 0) { + owner + ..setPixels(owner.pixels + physicsAppliedDelta) + ..didDragUpdateMetrics(details); + } + + final overflow = owner.physics.computeOverflow(details.deltaY, owner); + if (overflow != 0) { + owner.didOverflowBy(overflow); + } + } + + @override + void onDragEnd(SheetDragEndDetails details) { + owner + ..didDragEnd(details) + ..goBallistic(details.velocityY); + } + + @override + void onDragCancel(SheetDragCancelDetails details) { + owner + ..didDragCancel() + ..goBallistic(0); + } +} + +@internal +@optionalTypeArgs +mixin ControlledSheetActivityMixin + on SheetActivity { + late final AnimationController controller; + + final _completer = Completer(); + + Future get done => _completer.future; + + @factory + AnimationController createAnimationController(); + + TickerFuture onAnimationStart(); + + void onAnimationTick(); + + void onAnimationEnd() {} + + @override + double get velocity => controller.velocity; + + @override + SheetStatus get status => SheetStatus.animating; + + @override + void init(T delegate) { + super.init(delegate); + controller = createAnimationController()..addListener(onAnimationTick); + // Won't trigger if we dispose 'animation' first. + onAnimationStart().whenComplete(onAnimationEnd); + } + + @override + void dispose() { + controller.dispose(); + _completer.complete(); + super.dispose(); + } +} + +@internal +@optionalTypeArgs +mixin UserControlledSheetActivityMixin + on SheetActivity { + @override + SheetStatus get status => SheetStatus.dragging; + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + assert(owner.hasDimensions); + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + // We don't call `goSettling` here because the user is still + // manually controlling the sheet position. + } +} + +/// Appends the delta of the bottom viewport inset, which is typically +/// equal to the height of the on-screen keyboard, to the [activityOwner]'s +/// `pixels` to maintain the visual sheet position. +@internal +void absorbBottomViewportInset( + SheetPosition activityOwner, + EdgeInsets oldViewportInsets, +) { + final newInsets = activityOwner.viewportInsets; + final oldInsets = oldViewportInsets; + final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; + final newPixels = activityOwner.pixels - deltaInsetBottom; + if (newPixels != activityOwner.pixels) { + activityOwner + ..setPixels(newPixels) + ..didUpdateMetrics(); + } +} diff --git a/package/lib/src/foundation/sheet_content_scaffold.dart b/lib/src/foundation/sheet_content_scaffold.dart similarity index 91% rename from package/lib/src/foundation/sheet_content_scaffold.dart rename to lib/src/foundation/sheet_content_scaffold.dart index 50a15ebe..ce567eab 100644 --- a/package/lib/src/foundation/sheet_content_scaffold.dart +++ b/lib/src/foundation/sheet_content_scaffold.dart @@ -4,7 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import '../draggable/sheet_draggable.dart'; -import 'sheet_extent.dart'; +import 'sheet_position.dart'; +import 'sheet_position_scope.dart'; import 'sheet_viewport.dart'; class SheetContentScaffold extends StatelessWidget { @@ -107,8 +108,8 @@ sealed class ResizeScaffoldBehavior { bool maintainBottomBar, }) = _AvoidBottomInset; - // TODO: Implement ResizeScaffoldBehavior.doNotResize - // static const ResizeScaffoldBehavior doNotResize = _DoNotResize(); +// TODO: Implement ResizeScaffoldBehavior.doNotResize +// static const ResizeScaffoldBehavior doNotResize = _DoNotResize(); } // class _DoNotResize extends ResizeScaffoldBehavior { @@ -221,25 +222,26 @@ abstract class BottomBarVisibility implements Widget { abstract class _RenderBottomBarVisibility extends RenderTransform { _RenderBottomBarVisibility({ - required SheetExtent extent, - }) : _extent = extent, + required SheetPosition position, + }) : _position = position, super(transform: Matrix4.zero(), transformHitTests: true) { - _extent.addListener(invalidateVisibility); + _position.addListener(invalidateVisibility); } - SheetExtent _extent; + SheetPosition _position; + // ignore: avoid_setters_without_getters - set extent(SheetExtent value) { - if (_extent != value) { - _extent.removeListener(invalidateVisibility); - _extent = value..addListener(invalidateVisibility); + set position(SheetPosition value) { + if (_position != value) { + _position.removeListener(invalidateVisibility); + _position = value..addListener(invalidateVisibility); invalidateVisibility(); } } @override void dispose() { - _extent.removeListener(invalidateVisibility); + _position.removeListener(invalidateVisibility); super.dispose(); } @@ -256,11 +258,10 @@ abstract class _RenderBottomBarVisibility extends RenderTransform { void invalidateVisibility() { final size = _bottomBarSize; - if (size != null && _extent.metrics.hasDimensions) { - final metrics = _extent.metrics; - final baseTransition = (metrics.pixels - metrics.viewportSize.height) - .clamp(size.height - metrics.viewportSize.height, 0.0); - final visibility = computeVisibility(metrics, size); + if (size != null && _position.hasDimensions) { + final baseTransition = (_position.pixels - _position.viewportSize.height) + .clamp(size.height - _position.viewportSize.height, 0.0); + final visibility = computeVisibility(_position, size); assert(0 <= visibility && visibility <= 1); final invisibleHeight = size.height * (1 - visibility); final transition = baseTransition + invisibleHeight; @@ -299,7 +300,7 @@ class FixedBottomBarVisibility extends SingleChildRenderObjectWidget @override RenderObject createRenderObject(BuildContext context) { return _RenderFixedBottomBarVisibility( - extent: SheetExtentScope.of(context), + position: SheetPositionScope.of(context), resizeBehavior: _ResizeScaffoldBehaviorScope.of(context), ); } @@ -308,18 +309,19 @@ class FixedBottomBarVisibility extends SingleChildRenderObjectWidget void updateRenderObject(BuildContext context, RenderObject renderObject) { super.updateRenderObject(context, renderObject); (renderObject as _RenderFixedBottomBarVisibility) - ..extent = SheetExtentScope.of(context) + ..position = SheetPositionScope.of(context) ..resizeBehavior = _ResizeScaffoldBehaviorScope.of(context); } } class _RenderFixedBottomBarVisibility extends _RenderBottomBarVisibility { _RenderFixedBottomBarVisibility({ - required super.extent, + required super.position, required ResizeScaffoldBehavior resizeBehavior, }) : _resizeBehavior = resizeBehavior; ResizeScaffoldBehavior _resizeBehavior; + // ignore: avoid_setters_without_getters set resizeBehavior(ResizeScaffoldBehavior value) { if (_resizeBehavior != value) { @@ -387,7 +389,7 @@ class StickyBottomBarVisibility extends SingleChildRenderObjectWidget @override RenderObject createRenderObject(BuildContext context) { return _RenderStickyBottomBarVisibility( - extent: SheetExtentScope.of(context), + position: SheetPositionScope.of(context), resizeBehavior: _ResizeScaffoldBehaviorScope.of(context), ); } @@ -396,18 +398,19 @@ class StickyBottomBarVisibility extends SingleChildRenderObjectWidget void updateRenderObject(BuildContext context, RenderObject renderObject) { super.updateRenderObject(context, renderObject); (renderObject as _RenderStickyBottomBarVisibility) - ..extent = SheetExtentScope.of(context) + ..position = SheetPositionScope.of(context) ..resizeBehavior = _ResizeScaffoldBehaviorScope.of(context); } } class _RenderStickyBottomBarVisibility extends _RenderBottomBarVisibility { _RenderStickyBottomBarVisibility({ - required super.extent, + required super.position, required ResizeScaffoldBehavior resizeBehavior, }) : _resizeBehavior = resizeBehavior; ResizeScaffoldBehavior _resizeBehavior; + // ignore: avoid_setters_without_getters set resizeBehavior(ResizeScaffoldBehavior value) { if (_resizeBehavior != value) { @@ -450,7 +453,7 @@ class AnimatedBottomBarVisibility extends SingleChildRenderObjectWidget @override RenderObject createRenderObject(BuildContext context) { return _RenderAnimatedBottomBarVisibility( - extent: SheetExtentScope.of(context), + position: SheetPositionScope.of(context), visibility: visibility, ); } @@ -459,20 +462,21 @@ class AnimatedBottomBarVisibility extends SingleChildRenderObjectWidget void updateRenderObject(BuildContext context, RenderObject renderObject) { super.updateRenderObject(context, renderObject); (renderObject as _RenderAnimatedBottomBarVisibility) - ..extent = SheetExtentScope.of(context) + ..position = SheetPositionScope.of(context) ..visibility = visibility; } } class _RenderAnimatedBottomBarVisibility extends _RenderBottomBarVisibility { _RenderAnimatedBottomBarVisibility({ - required super.extent, + required super.position, required Animation visibility, }) : _visibility = visibility { _visibility.addListener(invalidateVisibility); } Animation _visibility; + // ignore: avoid_setters_without_getters set visibility(Animation value) { if (_visibility != value) { @@ -513,7 +517,7 @@ class _RenderAnimatedBottomBarVisibility extends _RenderBottomBarVisibility { /// getIsVisible: (metrics) => /// metrics.viewportInsets.bottom == 0 && /// metrics.pixels > -/// const Extent.proportional(0.5) +/// const SheetAnchor.proportional(0.5) /// .resolve(metrics.contentSize), /// child: BottomAppBar(), /// ), @@ -558,7 +562,7 @@ class _ConditionalStickyBottomBarVisibilityState with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animation _curveAnimation; - SheetExtent? _extent; + SheetPosition? _position; @override void initState() { @@ -574,7 +578,7 @@ class _ConditionalStickyBottomBarVisibilityState @override void dispose() { - _extent!.removeListener(_didSheetMetricsChanged); + _position!.removeListener(_didSheetMetricsChanged); _controller.dispose(); super.dispose(); } @@ -582,10 +586,10 @@ class _ConditionalStickyBottomBarVisibilityState @override void didChangeDependencies() { super.didChangeDependencies(); - final extent = SheetExtentScope.of(context); - if (_extent != extent) { - _extent?.removeListener(_didSheetMetricsChanged); - _extent = extent..addListener(_didSheetMetricsChanged); + final position = SheetPositionScope.of(context); + if (_position != position) { + _position?.removeListener(_didSheetMetricsChanged); + _position = position..addListener(_didSheetMetricsChanged); _didSheetMetricsChanged(); } } @@ -605,7 +609,7 @@ class _ConditionalStickyBottomBarVisibilityState void _didSheetMetricsChanged() { final isVisible = - _extent!.metrics.hasDimensions && widget.getIsVisible(_extent!.metrics); + _position!.hasDimensions && widget.getIsVisible(_position!); if (isVisible) { if (_controller.status != AnimationStatus.forward) { diff --git a/lib/src/foundation/sheet_context.dart b/lib/src/foundation/sheet_context.dart new file mode 100644 index 00000000..d3d222ab --- /dev/null +++ b/lib/src/foundation/sheet_context.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import 'sheet_position.dart'; + +/// An interface that provides a set of dependencies +/// required by [SheetPosition]. +@internal +abstract class SheetContext { + TickerProvider get vsync; + BuildContext? get notificationContext; + double get devicePixelRatio; +} + +@internal +@optionalTypeArgs +mixin SheetContextStateMixin + on State, TickerProviderStateMixin + implements SheetContext { + @override + TickerProvider get vsync => this; + + @override + BuildContext? get notificationContext => mounted ? context : null; + + @override + double get devicePixelRatio => + MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; +} diff --git a/package/lib/src/foundation/sheet_controller.dart b/lib/src/foundation/sheet_controller.dart similarity index 67% rename from package/lib/src/foundation/sheet_controller.dart rename to lib/src/foundation/sheet_controller.dart index d17992b7..994cb809 100644 --- a/package/lib/src/foundation/sheet_controller.dart +++ b/lib/src/foundation/sheet_controller.dart @@ -3,12 +3,12 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'sheet_extent.dart'; -import 'sheet_status.dart'; +import 'foundation.dart'; +import 'sheet_position.dart'; class SheetController extends ChangeNotifier - implements ValueListenable { - SheetExtent? _client; + implements ValueListenable { + SheetPosition? _client; /// A notifier which notifies listeners immediately when the [_client] fires. /// @@ -17,11 +17,24 @@ class SheetController extends ChangeNotifier /// not be notified during the middle of a frame. final _immediateListeners = ChangeNotifier(); + /// The current sheet position. + /// + /// Returns [SheetPosition.value] of the attached [SheetPosition], + /// or `null` if no [SheetPosition] is attached. @override - SheetMetrics get value => _client?.metrics ?? SheetMetrics.empty; + double? get value => _client?.value; SheetStatus? get status => _client?.status; + /// The current metrics of the sheet. + /// + /// Returns [SheetPosition.snapshot] of the attached [SheetPosition], + /// or [SheetMetrics.empty] if no [SheetPosition] is attached. + SheetMetrics get metrics => _client?.snapshot ?? SheetMetrics.empty; + + /// Whether a [SheetPosition] is attached to this controller. + bool get hasClient => _client != null; + @override void addListener(VoidCallback listener, {bool fireImmediately = false}) { if (fireImmediately) { @@ -37,17 +50,17 @@ class SheetController extends ChangeNotifier super.removeListener(listener); } - void attach(SheetExtent extent) { - if (_client case final oldExtent?) { - detach(oldExtent); + void attach(SheetPosition position) { + if (_client case final oldPosition?) { + detach(oldPosition); } - _client = extent..addListener(notifyListeners); + _client = position..addListener(notifyListeners); } - void detach(SheetExtent? extent) { - if (extent == _client) { - extent?.removeListener(notifyListeners); + void detach(SheetPosition? position) { + if (position == _client) { + position?.removeListener(notifyListeners); _client = null; } } @@ -60,7 +73,7 @@ class SheetController extends ChangeNotifier } Future animateTo( - Extent to, { + SheetAnchor to, { Duration duration = const Duration(milliseconds: 300), Curve curve = Curves.easeInOut, }) { @@ -105,7 +118,21 @@ class SheetControllerScope extends InheritedWidget { } static SheetController of(BuildContext context) { - return maybeOf(context)!; + final controller = maybeOf(context); + + assert((() { + if (controller == null) { + throw FlutterError( + 'No $SheetControllerScope ancestor could be found starting ' + 'from the context that was passed to $SheetControllerScope.of(). ' + 'The context used was:\n' + '$context', + ); + } + return true; + })()); + + return controller!; } @override @@ -114,41 +141,6 @@ class SheetControllerScope extends InheritedWidget { } } -/// A widget that ensures that a [SheetController] is available in the subtree. -/// -/// The [builder] callback will be called with the [controller] if it is -/// explicitly provided and is not null, or a [SheetController] that is hosted -/// in the nearest ancestor [SheetControllerScope]. If neither is found, a newly -/// created [SheetController] hosted in a [DefaultSheetController] will be -/// used as a fallback. -@internal -// TODO: Remove this. -class ImplicitSheetControllerScope extends StatelessWidget { - const ImplicitSheetControllerScope({ - super.key, - this.controller, - required this.builder, - }); - - final SheetController? controller; - final Widget Function(BuildContext, SheetController) builder; - - @override - Widget build(BuildContext context) { - return switch (controller ?? DefaultSheetController.maybeOf(context)) { - final controller? => builder(context, controller), - null => DefaultSheetController( - child: Builder( - builder: (context) { - final controller = DefaultSheetController.of(context); - return builder(context, controller); - }, - ), - ), - }; - } -} - class DefaultSheetController extends StatefulWidget { const DefaultSheetController({ super.key, diff --git a/package/lib/src/foundation/sheet_drag.dart b/lib/src/foundation/sheet_drag.dart similarity index 85% rename from package/lib/src/foundation/sheet_drag.dart rename to lib/src/foundation/sheet_drag.dart index 2fbeb3ed..276b239b 100644 --- a/package/lib/src/foundation/sheet_drag.dart +++ b/lib/src/foundation/sheet_drag.dart @@ -214,11 +214,18 @@ class SheetDragEndDetails extends SheetDragDetails { } } +/// Details for when a sheet drag is canceled. +class SheetDragCancelDetails extends SheetDragDetails { + /// Creates details for when a sheet drag is canceled. + SheetDragCancelDetails({required super.axisDirection}); +} + @internal abstract class SheetDragControllerTarget { VerticalDirection get dragAxisDirection; - void applyUserDragUpdate(Offset offset); - void applyUserDragEnd(Velocity velocity); + void onDragUpdate(SheetDragUpdateDetails details); + void onDragEnd(SheetDragEndDetails details); + void onDragCancel(SheetDragCancelDetails details); /// Returns the minimum number of pixels that the sheet being dragged /// will potentially consume for the given drag delta. @@ -235,13 +242,13 @@ class SheetDragController implements Drag, ScrollActivityDelegate { /// finger across the screen. SheetDragController({ required SheetDragControllerTarget target, - required SheetGestureTamperer? gestureTamperer, + required SheetGestureProxyMixin? gestureTamperer, required SheetDragStartDetails details, required VoidCallback onDragCanceled, required double? carriedVelocity, required double? motionStartDistanceThreshold, }) : _target = target, - _gestureTamperer = gestureTamperer, + _gestureProxy = gestureTamperer, _lastDetails = details, pointerDeviceKind = details.kind { // Actual work is done by this object. @@ -265,20 +272,24 @@ class SheetDragController implements Drag, ScrollActivityDelegate { /// to avoid duplicating the code of [ScrollDragController]. late final ScrollDragController _impl; - SheetDragControllerTarget? _target; - SheetGestureTamperer? _gestureTamperer; + late SheetDragControllerTarget _target; - SheetDragDetails _lastDetails; + SheetGestureProxyMixin? _gestureProxy; + + /// The details of the most recently observed drag event. SheetDragDetails get lastDetails => _lastDetails; + SheetDragDetails _lastDetails; + /// The most recently observed [DragStartDetails], [DragUpdateDetails], or + /// [DragEndDetails] object. dynamic get lastRawDetails => _impl.lastDetails; void updateTarget(SheetDragControllerTarget delegate) { _target = delegate; } - void updateGestureTamperer(SheetGestureTamperer? gestureTamperer) { - _gestureTamperer = gestureTamperer; + void updateGestureTamperer(SheetGestureProxyMixin? gestureTamperer) { + _gestureProxy = gestureTamperer; } @override @@ -296,29 +307,29 @@ class SheetDragController implements Drag, ScrollActivityDelegate { _impl.cancel(); } - /// Called by the [ScrollDragController] in [Drag.end] and [Drag.cancel]. + /// Called by the [ScrollDragController] in either [ScrollDragController.end] + /// or [ScrollDragController.cancel]. @override void goBallistic(double velocity) { - var details = switch (_impl.lastDetails) { - final DragEndDetails details => SheetDragEndDetails( - axisDirection: _target!.dragAxisDirection, - velocityX: details.velocity.pixelsPerSecond.dx, - velocityY: -1 * velocity, - ), - // Drag was canceled. - _ => SheetDragEndDetails( - axisDirection: _target!.dragAxisDirection, - velocityX: 0, - velocityY: 0, - ), - }; - - if (_gestureTamperer case final tamper?) { - details = tamper.tamperWithDragEnd(details); + if (_impl.lastDetails case final DragEndDetails rawDetails) { + var endDetails = SheetDragEndDetails( + axisDirection: _target.dragAxisDirection, + velocityX: rawDetails.velocity.pixelsPerSecond.dx, + velocityY: -1 * velocity, + ); + if (_gestureProxy case final tamper?) { + endDetails = tamper.onDragEnd(endDetails); + } + _lastDetails = endDetails; + _target.onDragEnd(endDetails); + } else { + final cancelDetails = SheetDragCancelDetails( + axisDirection: _target.dragAxisDirection, + ); + _lastDetails = cancelDetails; + _gestureProxy?.onDragCancel(cancelDetails); + _target.onDragCancel(cancelDetails); } - - _lastDetails = details; - _target!.applyUserDragEnd(details.velocity); } /// Called by the [ScrollDragController] in [Drag.update]. @@ -328,7 +339,7 @@ class SheetDragController implements Drag, ScrollActivityDelegate { final rawDetails = _impl.lastDetails as DragUpdateDetails; var details = SheetDragUpdateDetails( sourceTimeStamp: rawDetails.sourceTimeStamp, - axisDirection: _target!.dragAxisDirection, + axisDirection: _target.dragAxisDirection, localPositionX: rawDetails.localPosition.dx, localPositionY: rawDetails.localPosition.dy, globalPositionX: rawDetails.globalPosition.dx, @@ -337,24 +348,24 @@ class SheetDragController implements Drag, ScrollActivityDelegate { deltaY: delta, ); - if (_gestureTamperer case final tamper?) { + if (_gestureProxy case final tamper?) { final minPotentialDeltaConsumption = - _target!.computeMinPotentialDeltaConsumption(details.delta); + _target.computeMinPotentialDeltaConsumption(details.delta); assert(minPotentialDeltaConsumption.dx.abs() <= details.delta.dx.abs()); assert(minPotentialDeltaConsumption.dy.abs() <= details.delta.dy.abs()); - details = tamper.tamperWithDragUpdate( + details = tamper.onDragUpdate( details, minPotentialDeltaConsumption, ); } _lastDetails = details; - _target!.applyUserDragUpdate(details.delta); + _target.onDragUpdate(details); } @override AxisDirection get axisDirection { - return switch (_target!.dragAxisDirection) { + return switch (_target.dragAxisDirection) { VerticalDirection.up => AxisDirection.up, VerticalDirection.down => AxisDirection.down, }; @@ -373,8 +384,7 @@ class SheetDragController implements Drag, ScrollActivityDelegate { @mustCallSuper void dispose() { - _target = null; - _gestureTamperer = null; + _gestureProxy = null; _impl.dispose(); } } diff --git a/lib/src/foundation/sheet_gesture_tamperer.dart b/lib/src/foundation/sheet_gesture_tamperer.dart new file mode 100644 index 00000000..2142de01 --- /dev/null +++ b/lib/src/foundation/sheet_gesture_tamperer.dart @@ -0,0 +1,111 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import 'sheet_drag.dart'; + +// TODO: Expose this as a public API. +@internal +class SheetGestureProxy extends StatefulWidget { + const SheetGestureProxy({ + super.key, + required this.tamperer, + required this.child, + }); + + final SheetGestureProxyMixin tamperer; + final Widget child; + + @override + State createState() => _SheetGestureProxyState(); + + static SheetGestureProxyMixin? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_SheetGestureProxyScope>() + ?.tamperer; + } +} + +class _SheetGestureProxyState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + widget.tamperer.updateParent(SheetGestureProxy.maybeOf(context)); + } + + @override + void didUpdateWidget(SheetGestureProxy oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.tamperer.updateParent(null); + widget.tamperer.updateParent(SheetGestureProxy.maybeOf(context)); + } + + @override + void dispose() { + widget.tamperer.updateParent(null); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SheetGestureProxyScope( + tamperer: widget.tamperer, + child: widget.child, + ); + } +} + +class _SheetGestureProxyScope extends InheritedWidget { + const _SheetGestureProxyScope({ + required this.tamperer, + required super.child, + }); + + final SheetGestureProxyMixin tamperer; + + @override + bool updateShouldNotify(_SheetGestureProxyScope oldWidget) => + oldWidget.tamperer != tamperer; +} + +// TODO: Expose this as a public API. +@internal +mixin SheetGestureProxyMixin { + SheetGestureProxyMixin? _parent; + + @mustCallSuper + void updateParent(SheetGestureProxyMixin? parent) { + _parent = parent; + } + + @useResult + @mustCallSuper + SheetDragStartDetails onDragStart(SheetDragStartDetails details) { + return _parent?.onDragStart(details) ?? details; + } + + @useResult + @mustCallSuper + SheetDragUpdateDetails onDragUpdate( + SheetDragUpdateDetails details, + Offset minPotentialDeltaConsumption, + ) { + return switch (_parent) { + null => details, + final parent => parent.onDragUpdate( + details, + minPotentialDeltaConsumption, + ), + }; + } + + @useResult + @mustCallSuper + SheetDragEndDetails onDragEnd(SheetDragEndDetails details) { + return _parent?.onDragEnd(details) ?? details; + } + + @mustCallSuper + void onDragCancel(SheetDragCancelDetails details) { + _parent?.onDragCancel(details); + } +} diff --git a/package/lib/src/foundation/sheet_notification.dart b/lib/src/foundation/sheet_notification.dart similarity index 81% rename from package/lib/src/foundation/sheet_notification.dart rename to lib/src/foundation/sheet_notification.dart index 1f426805..1118eaf8 100644 --- a/package/lib/src/foundation/sheet_notification.dart +++ b/lib/src/foundation/sheet_notification.dart @@ -1,18 +1,18 @@ import 'package:flutter/widgets.dart'; import 'sheet_drag.dart'; -import 'sheet_extent.dart'; import 'sheet_physics.dart'; +import 'sheet_position.dart'; import 'sheet_status.dart'; -/// A [Notification] that is dispatched when the sheet extent changes. +/// A [Notification] that is dispatched when the sheet position changes. /// -/// Sheet widgets notify their ancestors about changes to their extent. -/// There are 5 types of notifications: +/// Sheet widgets notify their ancestors about changes to their position. +/// There are 6 types of notifications: /// - [SheetOverflowNotification], which is dispatched when the user tries /// to drag the sheet beyond its draggable bounds but the sheet has not -/// changed its extent because its [SheetPhysics] does not allow it to be. -/// - [SheetUpdateNotification], which is dispatched when the sheet extent +/// changed its position because its [SheetPhysics] does not allow it to be. +/// - [SheetUpdateNotification], which is dispatched when the sheet position /// is updated by other than user interaction such as animation. /// - [SheetDragUpdateNotification], which is dispatched when the sheet /// is dragged. @@ -20,6 +20,8 @@ import 'sheet_status.dart'; /// dragging the sheet. /// - [SheetDragEndNotification], which is dispatched when the user stops /// dragging the sheet. +/// - [SheetDragCancelNotification], which is dispatched when the user +/// or the system cancels a drag gesture in the sheet. /// /// See also: /// - [NotificationListener], which can be used to listen for notifications @@ -41,8 +43,8 @@ sealed class SheetNotification extends Notification { super.debugFillDescription(description); description ..add('pixels: ${metrics.pixels}') - ..add('minExtent: ${metrics.minPixels}') - ..add('maxExtent: ${metrics.maxPixels}') + ..add('minPosition: ${metrics.minPixels}') + ..add('maxPosition: ${metrics.maxPixels}') ..add('viewportSize: ${metrics.viewportSize}') ..add('viewportInsets: ${metrics.viewportInsets}') ..add('contentSize: ${metrics.contentSize}') @@ -50,7 +52,7 @@ sealed class SheetNotification extends Notification { } } -/// A [SheetNotification] that is dispatched when the sheet extent +/// A [SheetNotification] that is dispatched when the sheet position /// is updated by other than user interaction such as animation. class SheetUpdateNotification extends SheetNotification { const SheetUpdateNotification({ @@ -116,9 +118,19 @@ class SheetDragEndNotification extends SheetNotification { } } +/// A [SheetNotification] that is dispatched when the user +/// or the system cancels a drag gesture in the sheet. +class SheetDragCancelNotification extends SheetNotification { + /// Create a notification that is dispatched when a drag gesture + /// in the sheet is canceled. + const SheetDragCancelNotification({ + required super.metrics, + }) : super(status: SheetStatus.dragging); +} + /// A [SheetNotification] that is dispatched when the user tries /// to drag the sheet beyond its draggable bounds but the sheet has not -/// changed its extent because its [SheetPhysics] does not allow it to be. +/// changed its position because its [SheetPhysics] does not allow it to be. class SheetOverflowNotification extends SheetNotification { const SheetOverflowNotification({ required super.metrics, diff --git a/lib/src/foundation/sheet_physics.dart b/lib/src/foundation/sheet_physics.dart new file mode 100644 index 00000000..f7f7751f --- /dev/null +++ b/lib/src/foundation/sheet_physics.dart @@ -0,0 +1,647 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; +import 'sheet_position.dart'; + +/// The default [SpringDescription] used by [SheetPhysics] subclasses. +/// +/// This spring has the same configuration as the resulting spring +/// from the [SpringDescription.withDampingRatio] constructor with +/// a ratio of `1.1`, a mass of `0.5`, and a stiffness of `100.0`. +const kDefaultSheetSpring = SpringDescription( + mass: 0.5, + stiffness: 100.0, + // Use a pre-calculated value to define the spring as a const variable. + // See the implementation of withDampingRatio() for the formula. + damping: 15.5563491861, // 1.1 * 2.0 * sqrt(0.5 * 100.0) +); + +/// The default [SheetPhysics] used by sheet widgets. +const kDefaultSheetPhysics = + BouncingSheetPhysics(parent: SnappingSheetPhysics()); + +// TODO: Implement `equals` and `hashCode` for SheetPhysics classes. +abstract class SheetPhysics { + const SheetPhysics({this.parent}); + + final SheetPhysics? parent; + + /// The minimum amount of pixel distance drags must move by to start motion + /// the first time or after each time the drag motion stopped. + /// + /// If null, no minimum threshold is enforced. + double? get dragStartDistanceMotionThreshold => + switch (defaultTargetPlatform) { + TargetPlatform.iOS => + const BouncingScrollPhysics().dragStartDistanceMotionThreshold, + _ => null, + }; + + /// Create a copy of this object appending the [ancestor] to + /// the physics chain, much like [ScrollPhysics.applyTo]. + /// + /// Can be used to dynamically create an inheritance relationship + /// between [SheetPhysics] objects. For example, [SheetPhysics] `x` + /// and `y` in the following code will have the same behavior. + /// ```dart + /// final x = FooSheetPhysics().applyTo(BarSheetPhysics()); + /// final y = FooSheetPhysics(parent: BarSheetPhysics()); + /// ``` + SheetPhysics applyTo(SheetPhysics ancestor) { + return copyWith(parent: parent?.applyTo(ancestor) ?? ancestor); + } + + /// Create a copy of this object with the given fields replaced + /// by the new values. + SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}); + + double computeOverflow(double offset, SheetMetrics metrics); + + // TODO: Change to return a tuple of (physicsAppliedOffset, overflow) + // to avoid recomputation of the overflow. + double applyPhysicsToOffset(double offset, SheetMetrics metrics); + + Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics); + + /// {@template SheetPhysics.findSettledPosition} + /// Returns an position to which a sheet should eventually settle + /// based on the current [metrics] and the [velocity] of a sheet. + /// {@endtemplate} + SheetAnchor findSettledPosition(double velocity, SheetMetrics metrics); +} + +/// A mixin that provides default implementations for [SheetPhysics] methods. +mixin SheetPhysicsMixin on SheetPhysics { + SpringDescription get spring => kDefaultSheetSpring; + + @override + double computeOverflow(double offset, SheetMetrics metrics) { + if (parent case final parent?) { + return parent.computeOverflow(offset, metrics); + } + + final newPixels = metrics.pixels + offset; + if (newPixels > metrics.maxPixels) { + return min(newPixels - metrics.maxPixels, offset); + } else if (newPixels < metrics.minPixels) { + return max(newPixels - metrics.minPixels, offset); + } else { + return 0; + } + } + + @override + double applyPhysicsToOffset(double offset, SheetMetrics metrics) { + // TODO: Use computeOverflow() to calculate the overflowed pixels. + if (parent case final parent?) { + return parent.applyPhysicsToOffset(offset, metrics); + } else if (offset > 0 && metrics.pixels < metrics.maxPixels) { + // Prevent the pixels from going beyond the maximum value. + return min(metrics.maxPixels, metrics.pixels + offset) - metrics.pixels; + } else if (offset < 0 && metrics.pixels > metrics.minPixels) { + // Prevent the pixels from going beyond the minimum value. + return max(metrics.minPixels, metrics.pixels + offset) - metrics.pixels; + } else { + return 0; + } + } + + @override + Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { + if (parent case final parent?) { + return parent.createBallisticSimulation(velocity, metrics); + } + + // Ensure that this method always uses the default implementation + // of findSettledPosition. + final detent = _findSettledPositionInternal(velocity, metrics) + .resolve(metrics.contentSize); + if (FloatComp.distance(metrics.devicePixelRatio) + .isNotApprox(detent, metrics.pixels)) { + final direction = (detent - metrics.pixels).sign; + return ScrollSpringSimulation( + spring, + metrics.pixels, + detent, + // The simulation velocity is intentionally set to 0 if the velocity is + // is in the opposite direction of the destination, as flinging up an + // over-dragged sheet or flinging down an under-dragged sheet tends to + // cause unstable motion. + velocity.sign == direction ? velocity : 0.0, + ); + } + + return null; + } + + /// Returns the closer of [SheetMetrics.minPosition] or + /// [SheetMetrics.maxPosition] to the current sheet position + /// if it is out of bounds, regardless of the [velocity]. + /// Otherwise, it returns the current position. + @override + SheetAnchor findSettledPosition(double velocity, SheetMetrics metrics) { + return _findSettledPositionInternal(velocity, metrics); + } + + SheetAnchor _findSettledPositionInternal( + double velocity, SheetMetrics metrics) { + final pixels = metrics.pixels; + final minPixels = metrics.minPixels; + final maxPixels = metrics.maxPixels; + if (FloatComp.distance(metrics.devicePixelRatio) + .isInBoundsExclusive(pixels, minPixels, maxPixels)) { + return SheetAnchor.pixels(pixels); + } else if ((pixels - minPixels).abs() < (pixels - maxPixels).abs()) { + return metrics.minPosition; + } else { + return metrics.maxPosition; + } + } +} + +abstract interface class SnappingSheetBehavior { + /// {@macro SheetPhysics.findSettledPosition} + /// + /// Returning `null` indicates that this behavior has no preference for + /// for where the sheet should settle. + SheetAnchor? findSettledPosition(double velocity, SheetMetrics metrics); +} + +/// A [SnappingSheetBehavior] that snaps to either [SheetMetrics.minPixels] +/// or [SheetMetrics.maxPixels] based on the current sheet position and +/// the gesture velocity. +/// +/// If the absolute value of the gesture velocity is less than +/// [minFlingSpeed], the sheet will snap to the nearest of +/// [SheetMetrics.minPixels] and [SheetMetrics.maxPixels]. +/// Otherwise, the gesture is considered to be a fling, and the sheet will snap +/// towards the direction of the fling. For example, if the sheet is flung up, +/// it will snap to [SheetMetrics.maxPixels]. +/// +/// Using this behavior is functionally identical to using [SnapToNearest] +/// with the snap positions of [SheetPosition.minPosition] and +/// [SheetPosition.maxPosition], but more simplified and efficient. +class SnapToNearestEdge implements SnappingSheetBehavior { + /// Creates a [SnappingSheetBehavior] that snaps to either + /// [SheetMetrics.minPixels] or [SheetMetrics.maxPixels]. + /// + /// The [minFlingSpeed] defaults to [kMinFlingVelocity], + /// and must be non-negative. + const SnapToNearestEdge({ + this.minFlingSpeed = kMinFlingVelocity, + }) : assert(minFlingSpeed >= 0); + + /// The lowest speed (in logical pixels per second) + /// at which a gesture is considered to be a fling. + final double minFlingSpeed; + + @override + SheetAnchor? findSettledPosition(double velocity, SheetMetrics metrics) { + assert(minFlingSpeed >= 0); + final pixels = metrics.pixels; + final minPixels = metrics.minPixels; + final maxPixels = metrics.maxPixels; + final cmp = FloatComp.distance(metrics.devicePixelRatio); + if (cmp.isOutOfBounds(pixels, minPixels, maxPixels)) { + return null; + } + if (velocity >= minFlingSpeed) { + return metrics.maxPosition; + } + if (velocity <= -minFlingSpeed) { + return metrics.minPosition; + } + if (cmp.isApprox(pixels, minPixels) || cmp.isApprox(pixels, maxPixels)) { + return null; + } + return (pixels - minPixels).abs() < (pixels - maxPixels).abs() + ? metrics.minPosition + : metrics.maxPosition; + } +} + +class SnapToNearest implements SnappingSheetBehavior { + const SnapToNearest({ + required this.anchors, + this.minFlingSpeed = kMinFlingVelocity, + }) : assert(minFlingSpeed >= 0); + + final List anchors; + + /// The lowest speed (in logical pixels per second) + /// at which a gesture is considered to be a fling. + final double minFlingSpeed; + + @override + SheetAnchor? findSettledPosition(double velocity, SheetMetrics metrics) { + if (anchors.length <= 1) { + return anchors.firstOrNull; + } + + final (sortedDetents, nearestIndex) = sortPositionsAndFindNearest( + anchors, metrics.pixels, metrics.contentSize); + final cmp = FloatComp.distance(metrics.devicePixelRatio); + final pixels = metrics.pixels; + + if (cmp.isOutOfBounds( + pixels, + sortedDetents.first.resolved, + sortedDetents.last.resolved, + )) { + return null; + } + + final nearest = sortedDetents[nearestIndex]; + if (velocity.abs() < minFlingSpeed) { + return cmp.isApprox(pixels, nearest.resolved) ? null : nearest.position; + } + + final int floorIndex; + final int ceilIndex; + if (cmp.isApprox(pixels, nearest.resolved)) { + floorIndex = max(nearestIndex - 1, 0); + ceilIndex = min(nearestIndex + 1, sortedDetents.length - 1); + } else if (pixels < nearest.resolved) { + floorIndex = max(nearestIndex - 1, 0); + ceilIndex = nearestIndex; + } else { + assert(pixels > nearest.resolved); + floorIndex = nearestIndex; + ceilIndex = min(nearestIndex + 1, sortedDetents.length - 1); + } + + assert(velocity.abs() >= minFlingSpeed); + return velocity < 0 + ? sortedDetents[floorIndex].position + : sortedDetents[ceilIndex].position; + } +} + +typedef _SortedPositionList = List<({SheetAnchor position, double resolved})>; + +/// Sorts the [positions] based on their resolved values and finds the nearest +/// position to the [pixels]. +/// +/// Returns a sorted copy of the [positions] and the index of the nearest +/// position. Note that the returned list may have a fixed length for better +/// performance. +@visibleForTesting +(_SortedPositionList, int) sortPositionsAndFindNearest( + List positions, + double pixels, + Size contentSize, +) { + assert(positions.isNotEmpty); + switch (positions) { + case [final a, final b]: + return _sortTwoPositionsAndFindNearest(a, b, pixels, contentSize); + case [final a, final b, final c]: + return _sortThreePositionsAndFindNearest(a, b, c, pixels, contentSize); + case _: + final sortedPositions = positions + .map((e) => (position: e, resolved: e.resolve(contentSize))) + .sorted((a, b) => a.resolved.compareTo(b.resolved)); + final nearestIndex = sortedPositions + .mapIndexed((i, e) => (index: i, dist: (pixels - e.resolved).abs())) + .reduce((a, b) => a.dist < b.dist ? a : b) + .index; + return (sortedPositions, nearestIndex); + } +} + +/// Constant time sorting and nearest neighbor search for two [SheetAnchor]s. +(_SortedPositionList, int) _sortTwoPositionsAndFindNearest( + SheetAnchor a, + SheetAnchor b, + double pixels, + Size contentSize, +) { + var first = (position: a, resolved: a.resolve(contentSize)); + var second = (position: b, resolved: b.resolve(contentSize)); + + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; + } + + final distToFirst = (pixels - first.resolved).abs(); + final distToSecond = (pixels - second.resolved).abs(); + final nearestIndex = distToFirst < distToSecond ? 0 : 1; + + return ( + // Create a fixed-length list. + List.filled(2, first)..[1] = second, + nearestIndex, + ); +} + +/// Constant time sorting and nearest neighbor search for three [SheetAnchor]s. +(_SortedPositionList, int) _sortThreePositionsAndFindNearest( + SheetAnchor a, + SheetAnchor b, + SheetAnchor c, + double pixels, + Size contentSize, +) { + var first = (position: a, resolved: a.resolve(contentSize)); + var second = (position: b, resolved: b.resolve(contentSize)); + var third = (position: c, resolved: c.resolve(contentSize)); + + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; + } + if (second.resolved > third.resolved) { + final temp = second; + second = third; + third = temp; + } + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; + } + + final distToFirst = (pixels - first.resolved).abs(); + final distToSecond = (pixels - second.resolved).abs(); + final distToThird = (pixels - third.resolved).abs(); + + final int nearestIndex; + if (distToFirst < distToSecond && distToFirst < distToThird) { + nearestIndex = 0; + } else if (distToSecond < distToFirst && distToSecond < distToThird) { + nearestIndex = 1; + } else { + nearestIndex = 2; + } + + return ( + // Create a fixed-length list. + List.filled(3, first) + ..[1] = second + ..[2] = third, + nearestIndex, + ); +} + +class SnappingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { + const SnappingSheetPhysics({ + super.parent, + this.spring = kDefaultSheetSpring, + this.behavior = const SnapToNearestEdge(), + }); + + final SnappingSheetBehavior behavior; + + @override + final SpringDescription spring; + + @override + SheetPhysics copyWith({ + SheetPhysics? parent, + SpringDescription? spring, + SnappingSheetBehavior? snappingBehavior, + }) { + return SnappingSheetPhysics( + parent: parent ?? this.parent, + spring: spring ?? this.spring, + behavior: snappingBehavior ?? this.behavior, + ); + } + + @override + Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { + final detent = behavior + .findSettledPosition(velocity, metrics) + ?.resolve(metrics.contentSize); + if (detent != null && + FloatComp.distance(metrics.devicePixelRatio) + .isNotApprox(detent, metrics.pixels)) { + return ScrollSpringSimulation( + spring, + metrics.pixels, + detent, + velocity, + ); + } else { + return super.createBallisticSimulation(velocity, metrics); + } + } + + @override + SheetAnchor findSettledPosition(double velocity, SheetMetrics metrics) { + return behavior.findSettledPosition(velocity, metrics) ?? + super.findSettledPosition(velocity, metrics); + } +} + +class ClampingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { + const ClampingSheetPhysics({ + super.parent, + this.spring = kDefaultSheetSpring, + }); + + @override + final SpringDescription spring; + + @override + SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}) { + return ClampingSheetPhysics( + parent: parent ?? this.parent, + spring: spring ?? this.spring, + ); + } +} + +/// {@template BouncingBehavior} +/// An object that determines the behavior of a bounceable sheet +/// when it is out of the content bounds. +/// {@endtemplate} +/// +/// See also: +/// - [FixedBouncingBehavior], which allows the sheet position to exceed the +/// content bounds by a fixed amount. +/// - [DirectionAwareBouncingBehavior], which allows the sheet position to +/// exceed the content bounds by different amounts based on the direction +/// of a drag gesture. +abstract class BouncingBehavior { + /// Returns the number of pixels that the sheet position can go beyond + /// the content bounds. + /// + /// [BouncingSheetPhysics.applyPhysicsToOffset] calls this method to calculate + /// the amount of friction that should be applied to the given drag [offset]. + /// + /// The returned value must be non-negative. Since this method may be called + /// every frame, and even multiple times per frame, it is not recommended to + /// return different values for each call, as it may cause unstable motion. + double computeBounceablePixels(double offset, SheetMetrics metrics); +} + +/// A [BouncingBehavior] that allows the sheet position to exceed the content +/// bounds by a fixed amount. +/// +/// The following is an example of a [BouncingSheetPhysics] that allows the +/// sheet position to go beyond the [SheetMetrics.maxPixels] or +/// [SheetMetrics.minPixels] by 12% of the content size. +/// +/// ```dart +/// const physics = BouncingSheetPhysics( +/// behavior: FixedBouncingBehavior(SheetAnchor.proportional(0.12)), +/// ); +/// ``` +class FixedBouncingBehavior implements BouncingBehavior { + /// Creates a [BouncingBehavior] that allows the sheet to bounce by a fixed + /// amount. + const FixedBouncingBehavior(this.range); + + /// How much the sheet can bounce beyond the content bounds. + final SheetAnchor range; + + @override + double computeBounceablePixels(double offset, SheetMetrics metrics) { + return range.resolve(metrics.contentSize); + } +} + +/// A [BouncingBehavior] that allows the sheet position to exceed the content +/// bounds by different amounts based on the direction of a drag gesture. +/// +/// Different bounceable amounts can be specified for upward and downward +/// directions. For example, the following [BouncingSheetPhysics] allows the +/// sheet to bounce by 12% of the content size when dragged downward, and by +/// 8 pixels when dragged upward. +/// +/// ```dart +/// const physics = BouncingSheetPhysics( +/// behavior: DirectionAwareBouncingBehavior( +/// upward: SheetAnchor.pixels(8), +/// downward: SheetAnchor.proportional(0.12), +/// ), +/// ); +/// ``` +class DirectionAwareBouncingBehavior implements BouncingBehavior { + /// Creates a [BouncingBehavior] that allows the sheet to bounce by different + /// amounts based on the direction of a drag gesture. + const DirectionAwareBouncingBehavior({ + this.upward = const SheetAnchor.pixels(0), + this.downward = const SheetAnchor.pixels(0), + }); + + /// Amount of bounceable pixels when dragged upward. + final SheetAnchor upward; + + /// Amount of bounceable pixels when dragged downward. + final SheetAnchor downward; + + @override + double computeBounceablePixels(double offset, SheetMetrics metrics) { + return switch (offset) { + > 0.0 => upward.resolve(metrics.contentSize), + < 0.0 => downward.resolve(metrics.contentSize), + _ => 0.0, + }; + } +} + +class BouncingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { + const BouncingSheetPhysics({ + super.parent, + this.behavior = const FixedBouncingBehavior(SheetAnchor.proportional(0.12)), + this.frictionCurve = Curves.easeOutSine, + this.spring = kDefaultSheetSpring, + }); + + /// {@macro BouncingBehavior} + final BouncingBehavior behavior; + + final Curve frictionCurve; + + @override + final SpringDescription spring; + + @override + SheetPhysics copyWith({ + SheetPhysics? parent, + SpringDescription? spring, + BouncingBehavior? behavior, + Curve? frictionCurve, + }) { + return BouncingSheetPhysics( + parent: parent ?? this.parent, + spring: spring ?? this.spring, + behavior: behavior ?? this.behavior, + frictionCurve: frictionCurve ?? this.frictionCurve, + ); + } + + @override + double computeOverflow(double offset, SheetMetrics metrics) { + final bounceableRange = behavior.computeBounceablePixels(offset, metrics); + if (bounceableRange != 0) { + return const ClampingSheetPhysics().applyPhysicsToOffset(offset, metrics); + } + + return super.computeOverflow(offset, metrics); + } + + @override + double applyPhysicsToOffset(double offset, SheetMetrics metrics) { + final bounceablePixels = behavior.computeBounceablePixels(offset, metrics); + if (bounceablePixels == 0) { + return const ClampingSheetPhysics().applyPhysicsToOffset(offset, metrics); + } + + final currentPixels = metrics.pixels; + final minPixels = metrics.minPixels; + final maxPixels = metrics.maxPixels; + + // A part of or the entire offset that is not affected by friction. + // If the current 'pixels' plus the offset exceeds the content bounds, + // only the exceeding part is affected by friction. Otherwise, friction + // is not applied to the offset at all. + final zeroFrictionOffset = switch (offset) { + > 0 => max(min(currentPixels + offset, maxPixels) - currentPixels, 0.0), + < 0 => min(max(currentPixels + offset, minPixels) - currentPixels, 0.0), + _ => 0.0, + }; + + if (FloatComp.distance(metrics.devicePixelRatio) + .isApprox(zeroFrictionOffset, offset) || + // The friction is also not applied if the motion + // direction is towards the content bounds. + (currentPixels > maxPixels && offset < 0) || + (currentPixels < minPixels && offset > 0)) { + return offset; + } + + // We divide the delta into smaller fragments and apply friction to each + // fragment in sequence. This ensures that the friction is not too small + // if the delta is too large relative to the exceeding pixels, preventing + // the sheet from slipping too far. + const offsetSlop = 18.0; + var newPixels = currentPixels; + var consumedOffset = zeroFrictionOffset; + while (consumedOffset.abs() < offset.abs()) { + final fragment = (offset - consumedOffset).clampAbs(offsetSlop); + final overflowPastStart = max(minPixels - (newPixels + fragment), 0.0); + final overflowPastEnd = max(newPixels + fragment - maxPixels, 0.0); + final overflowPast = max(overflowPastStart, overflowPastEnd); + final overflowFraction = (overflowPast / bounceablePixels).clampAbs(1); + final frictionFactor = frictionCurve.transform(overflowFraction); + + newPixels += fragment * (1.0 - frictionFactor); + consumedOffset += fragment; + } + + return newPixels - currentPixels; + } +} diff --git a/lib/src/foundation/sheet_position.dart b/lib/src/foundation/sheet_position.dart new file mode 100644 index 00000000..49fc643c --- /dev/null +++ b/lib/src/foundation/sheet_position.dart @@ -0,0 +1,858 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import '../internal/float_comp.dart'; +import 'sheet_activity.dart'; +import 'sheet_context.dart'; +import 'sheet_controller.dart'; +import 'sheet_drag.dart'; +import 'sheet_gesture_tamperer.dart'; +import 'sheet_notification.dart'; +import 'sheet_physics.dart'; +import 'sheet_position_scope.dart'; +import 'sheet_status.dart'; + +/// An abstract representation of a sheet's position. +/// +/// It is used in various contexts by sheets, for example, +/// to define how much of the sheet is initially visible at the first build +/// or to limit the range within which the sheet can be dragged. +/// +/// See also: +/// - [ProportionalSheetAnchor], which defines the position +/// proportionally to the sheet's content height. +/// - [FixedSheetAnchor], which defines the position +/// using a fixed value in pixels. +abstract interface class SheetAnchor { + /// {@macro FixedSheetAnchor} + const factory SheetAnchor.pixels(double pixels) = FixedSheetAnchor; + + /// {@macro ProportionalSheetAnchor} + const factory SheetAnchor.proportional(double size) = ProportionalSheetAnchor; + + /// Resolves the position to an actual value in pixels. + /// + /// The [contentSize] parameter should not be cached + /// as it may change over time. + double resolve(Size contentSize); +} + +/// A [SheetAnchor] that represents a position proportional +/// to the content height of the sheet. +class ProportionalSheetAnchor implements SheetAnchor { + /// {@template ProportionalSheetAnchor} + /// Creates an anchor that positions the sheet + /// proportionally to its content height. + /// + /// The [factor] must be greater than or equal to 0. + /// This anchor resolves to `contentSize.height * factor`. + /// For example, `ProportionalSheetAnchor(0.6)` represents a position + /// where 60% of the sheet content is visible. + /// {@endtemplate} + const ProportionalSheetAnchor(this.factor) : assert(factor >= 0); + + /// The proportion of the sheet's content height. + /// + /// This value is a fraction (e.g., 0.6 for 60% visibility). + final double factor; + + @override + double resolve(Size contentSize) => contentSize.height * factor; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProportionalSheetAnchor && + runtimeType == other.runtimeType && + factor == other.factor); + + @override + int get hashCode => Object.hash(runtimeType, factor); + + @override + String toString() => '$ProportionalSheetAnchor(factor: $factor)'; +} + +/// A [SheetAnchor] that represents a position with a fixed value in pixels. +class FixedSheetAnchor implements SheetAnchor { + /// {@template FixedSheetAnchor} + /// Creates an anchor that represents a fixed position in pixels. + /// + /// For example, `FixedSheetAnchor(200)` represents a position + /// where 200 pixels from the top of the sheet content are visible. + /// {@endtemplate} + const FixedSheetAnchor(this.pixels) : assert(pixels >= 0); + + /// The position in pixels. + final double pixels; + + @override + double resolve(Size contentSize) => pixels; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FixedSheetAnchor && + runtimeType == other.runtimeType && + pixels == other.pixels); + + @override + int get hashCode => Object.hash(runtimeType, pixels); + + @override + String toString() => '$FixedSheetAnchor(pixels: $pixels)'; +} + +/// Manages the position of a sheet. +/// +/// This object is much like [ScrollPosition] for scrollable widgets. +/// The [SheetPosition.pixels] value determines the visible height of a sheet. +/// As this value changes, the sheet translates its position, which changes the +/// visible area of the content. The [SheetPosition.minPixels] and +/// [SheetPosition.maxPixels] values limit the range of the *pixels*, but it can +/// be outside of the range if the [SheetPosition.physics] allows it. +/// +/// The current [activity] is responsible for how the *pixels* changes +/// over time, for example, [AnimatedSheetActivity] animates the *pixels* to +/// a target value, and [IdleSheetActivity] keeps the *pixels* unchanged. +/// [SheetPosition] starts with [IdleSheetActivity] as the initial activity, +/// and it can be changed by calling [beginActivity]. +/// +/// This object is a [Listenable] that notifies its listeners when the *pixels* +/// changes, even during build or layout phase. For listeners that can cause +/// any widget to rebuild, consider using [SheetController], which is also +/// [Listenable] of the *pixels*, but avoids notifying listeners during a build. +/// +/// See also: +/// - [SheetController], which can be attached to a sheet to observe and control +/// its position. +/// - [SheetPositionScope], which creates a [SheetPosition], manages its +/// lifecycle and exposes it to the descendant widgets. +@internal +@optionalTypeArgs +abstract class SheetPosition extends ChangeNotifier + with SheetMetrics + implements ValueListenable { + /// Creates an object that manages the position of a sheet. + SheetPosition({ + required this.context, + required SheetAnchor minPosition, + required SheetAnchor maxPosition, + required SheetPhysics physics, + this.debugLabel, + SheetGestureProxyMixin? gestureTamperer, + }) : _physics = physics, + _gestureTamperer = gestureTamperer, + _snapshot = SheetMetrics.empty.copyWith( + minPosition: minPosition, + maxPosition: maxPosition, + ) { + goIdle(); + } + + @override + double? get value => snapshot.maybePixels; + + @override + double? get maybePixels => snapshot.maybePixels; + + @override + SheetAnchor? get maybeMinPosition => snapshot.maybeMinPosition; + + @override + SheetAnchor? get maybeMaxPosition => snapshot.maybeMaxPosition; + + @override + Size? get maybeContentSize => snapshot.maybeContentSize; + + @override + Size? get maybeViewportSize => snapshot.maybeViewportSize; + + @override + EdgeInsets? get maybeViewportInsets => snapshot.maybeViewportInsets; + + @override + double get devicePixelRatio => context.devicePixelRatio; + + SheetStatus get status => activity.status; + + /// A handle to the owner of this object. + final SheetContext context; + + /// {@template SheetPosition.physics} + /// How the sheet position should respond to user input. + /// + /// This determines how the sheet will behave when over-dragged or + /// under-dragged, or when the user stops dragging. + /// {@endtemplate} + SheetPhysics get physics => _physics; + SheetPhysics _physics; + + /// {@template SheetPosition.gestureTamperer} + /// An object that can modify the gesture details of the sheet. + /// {@endtemplate} + SheetGestureProxyMixin? get gestureTamperer => _gestureTamperer; + SheetGestureProxyMixin? _gestureTamperer; + + /// A label that is used to identify this object in debug output. + final String? debugLabel; + + /// The current activity of the sheet. + SheetActivity get activity => _activity!; + SheetActivity? _activity; + + /// The current drag that is currently driving the sheet. + /// + /// Intentionally exposed so that a subclass can override + /// the default implementation of [drag]. + @protected + SheetDragController? currentDrag; + + /// Snapshot of the current sheet's state. + SheetMetrics get snapshot => _snapshot; + SheetMetrics _snapshot; + + /// Updates the metrics with the given values. + /// + /// Use this method instead of directly updating the metrics + /// to ensure that the [SheetMetrics.devicePixelRatio] is always up-to-date. + void _updateMetrics({ + double? pixels, + SheetAnchor? minPosition, + SheetAnchor? maxPosition, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + }) { + _snapshot = SheetMetricsSnapshot( + pixels: pixels ?? maybePixels, + minPosition: minPosition ?? maybeMinPosition, + maxPosition: maxPosition ?? maybeMaxPosition, + contentSize: contentSize ?? maybeContentSize, + viewportSize: viewportSize ?? maybeViewportSize, + viewportInsets: viewportInsets ?? maybeViewportInsets, + // Ensure that the devicePixelRatio is always up-to-date. + devicePixelRatio: context.devicePixelRatio, + ); + } + + @mustCallSuper + void takeOver(SheetPosition other) { + assert(currentDrag == null); + if (other.activity.isCompatibleWith(this)) { + activity.dispose(); + _activity = other.activity; + // This is necessary to prevent the activity from being disposed of + // when `other` is disposed of. + other._activity = null; + activity.updateOwner(this); + + if ((other.currentDrag, activity) + case (final drag?, final SheetDragControllerTarget dragActivity)) { + currentDrag = drag..updateTarget(dragActivity); + other.currentDrag = null; + } + } else { + goIdle(); + } + if (other.maybePixels case final pixels?) { + correctPixels(pixels); + } + applyNewBoundaryConstraints(other.minPosition, other.maxPosition); + applyNewViewportDimensions( + other.viewportSize, + other.viewportInsets, + ); + applyNewContentSize(other.contentSize); + } + + @mustCallSuper + // TODO: Rename to updateGestureProxy + void updateGestureTamperer(SheetGestureProxyMixin? gestureTamperer) { + if (_gestureTamperer != gestureTamperer) { + _gestureTamperer = gestureTamperer; + currentDrag?.updateGestureTamperer(gestureTamperer); + } + } + + @mustCallSuper + void updatePhysics(SheetPhysics physics) { + _physics = physics; + } + + @mustCallSuper + void applyNewContentSize(Size contentSize) { + if (maybeContentSize != contentSize) { + _oldContentSize = maybeContentSize; + _updateMetrics(contentSize: contentSize); + activity.didChangeContentSize(_oldContentSize); + } + } + + @mustCallSuper + void applyNewViewportDimensions(Size size, EdgeInsets insets) { + if (maybeViewportSize != size || maybeViewportInsets != insets) { + _oldViewportSize = maybeViewportSize; + _oldViewportInsets = maybeViewportInsets; + _updateMetrics(viewportSize: size, viewportInsets: insets); + activity.didChangeViewportDimensions( + _oldViewportSize, + _oldViewportInsets, + ); + } + } + + @mustCallSuper + void applyNewBoundaryConstraints( + SheetAnchor minPosition, SheetAnchor maxPosition) { + if (minPosition != this.minPosition || maxPosition != this.maxPosition) { + final oldMinPosition = maybeMinPosition; + final oldMaxPosition = maybeMaxPosition; + _updateMetrics(minPosition: minPosition, maxPosition: maxPosition); + activity.didChangeBoundaryConstraints(oldMinPosition, oldMaxPosition); + } + } + + Size? _oldContentSize; + Size? _oldViewportSize; + EdgeInsets? _oldViewportInsets; + int _markAsDimensionsWillChangeCallCount = 0; + + @mustCallSuper + void markAsDimensionsWillChange() { + assert(() { + if (_markAsDimensionsWillChangeCallCount == 0) { + // Ensure that the number of calls to markAsDimensionsWillChange() + // matches the number of calls to markAsDimensionsChanged(). + WidgetsBinding.instance.addPostFrameCallback((_) { + assert( + _markAsDimensionsWillChangeCallCount == 0, + _markAsDimensionsWillChangeCallCount > 0 + ? _debugMessage( + 'markAsDimensionsWillChange() was called more times ' + 'than markAsDimensionsChanged() in a frame.', + ) + : _debugMessage( + 'markAsDimensionsChanged() was called more times ' + 'than markAsDimensionsWillChange() in a frame.', + ), + ); + }); + } + return true; + }()); + + if (_markAsDimensionsWillChangeCallCount == 0) { + _oldContentSize = null; + _oldViewportSize = null; + _oldViewportInsets = null; + } + + _markAsDimensionsWillChangeCallCount++; + } + + @mustCallSuper + void markAsDimensionsChanged() { + assert( + _markAsDimensionsWillChangeCallCount > 0, + _debugMessage( + 'markAsDimensionsChanged() called without ' + 'a matching call to markAsDimensionsWillChange().', + ), + ); + + _markAsDimensionsWillChangeCallCount--; + if (_markAsDimensionsWillChangeCallCount == 0) { + onDimensionsFinalized(); + } + } + + @mustCallSuper + void onDimensionsFinalized() { + assert( + _markAsDimensionsWillChangeCallCount == 0, + _debugMessage( + 'Do not call this method until all dimensions changes are finalized.', + ), + ); + assert( + hasDimensions, + _debugMessage( + 'All the dimension values must be finalized ' + 'at the time onDimensionsFinalized() is called.', + ), + ); + + _activity!.didFinalizeDimensions( + _oldContentSize, + _oldViewportSize, + _oldViewportInsets, + ); + + _oldContentSize = null; + _oldViewportSize = null; + _oldViewportInsets = null; + } + + @mustCallSuper + void beginActivity(SheetActivity activity) { + assert((_activity is SheetDragControllerTarget) == (currentDrag != null)); + currentDrag?.dispose(); + currentDrag = null; + + final oldActivity = _activity; + // Update the current activity before initialization. + _activity = activity; + activity.init(this); + oldActivity?.dispose(); + } + + void goIdle() { + beginActivity(IdleSheetActivity()); + } + + void goBallistic(double velocity) { + assert(hasDimensions); + final simulation = physics.createBallisticSimulation(velocity, snapshot); + if (simulation != null) { + goBallisticWith(simulation); + } else { + goIdle(); + } + } + + void goBallisticWith(Simulation simulation) { + beginActivity(BallisticSheetActivity(simulation: simulation)); + } + + void settleTo(SheetAnchor detent, Duration duration) { + beginActivity( + SettlingSheetActivity.withDuration( + duration, + destination: detent, + ), + ); + } + + Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { + assert(currentDrag == null); + final dragActivity = DragSheetActivity(); + var startDetails = SheetDragStartDetails( + sourceTimeStamp: details.sourceTimeStamp, + axisDirection: dragActivity.dragAxisDirection, + localPositionX: details.localPosition.dx, + localPositionY: details.localPosition.dy, + globalPositionX: details.globalPosition.dx, + globalPositionY: details.globalPosition.dy, + kind: details.kind, + ); + if (_gestureTamperer case final tamperer?) { + startDetails = tamperer.onDragStart(startDetails); + } + + final drag = SheetDragController( + target: dragActivity, + gestureTamperer: _gestureTamperer, + details: startDetails, + onDragCanceled: dragCancelCallback, + // TODO: Specify a correct value. + carriedVelocity: 0, + motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, + ); + beginActivity(dragActivity); + currentDrag = drag; + didDragStart(startDetails); + return drag; + } + + @override + void dispose() { + _activity?.dispose(); + currentDrag?.dispose(); + _activity = null; + currentDrag = null; + super.dispose(); + } + + void setPixels(double pixels) { + final oldPixels = maybePixels; + correctPixels(pixels); + if (oldPixels != pixels) { + notifyListeners(); + } + } + + void correctPixels(double pixels) { + if (maybePixels != pixels) { + _updateMetrics(pixels: pixels); + } + } + + /// Animates the sheet position to the given value. + /// + /// The returned future completes when the animation ends, + /// whether it completed successfully or whether it was + /// interrupted prematurely. + Future animateTo( + SheetAnchor newPosition, { + Curve curve = Curves.easeInOut, + Duration duration = const Duration(milliseconds: 300), + }) { + assert(hasDimensions); + if (pixels == newPosition.resolve(contentSize)) { + return Future.value(); + } else { + final activity = AnimatedSheetActivity( + destination: newPosition, + duration: duration, + curve: curve, + ); + + beginActivity(activity); + return activity.done; + } + } + + @override + SheetMetrics copyWith({ + double? pixels, + SheetAnchor? minPosition, + SheetAnchor? maxPosition, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + double? devicePixelRatio, + }) { + return snapshot.copyWith( + pixels: pixels, + minPosition: minPosition, + maxPosition: maxPosition, + contentSize: contentSize, + viewportSize: viewportSize, + viewportInsets: viewportInsets, + devicePixelRatio: devicePixelRatio, + ); + } + + void didUpdateMetrics() { + if (hasDimensions) { + SheetUpdateNotification( + metrics: snapshot, + status: status, + ).dispatch(context.notificationContext); + } + } + + void didDragStart(SheetDragStartDetails details) { + assert(hasDimensions); + SheetDragStartNotification( + metrics: snapshot, + dragDetails: details, + ).dispatch(context.notificationContext); + } + + void didDragEnd(SheetDragEndDetails details) { + assert(hasDimensions); + SheetDragEndNotification( + metrics: snapshot, + dragDetails: details, + ).dispatch(context.notificationContext); + } + + void didDragUpdateMetrics(SheetDragUpdateDetails details) { + assert(hasDimensions); + SheetDragUpdateNotification( + metrics: snapshot, + dragDetails: details, + ).dispatch(context.notificationContext); + } + + void didDragCancel() { + assert(hasDimensions); + SheetDragCancelNotification( + metrics: snapshot, + ).dispatch(context.notificationContext); + } + + void didOverflowBy(double overflow) { + assert(hasDimensions); + SheetOverflowNotification( + metrics: snapshot, + status: status, + overflow: overflow, + ).dispatch(context.notificationContext); + } + + String _debugMessage(String message) { + return switch (debugLabel) { + null => message, + final debugLabel => '$debugLabel: $message', + }; + } +} + +/// The metrics of a sheet. +mixin SheetMetrics { + /// An empty metrics object with all values set to null. + static const SheetMetrics empty = SheetMetricsSnapshot( + pixels: null, + minPosition: null, + maxPosition: null, + contentSize: null, + viewportSize: null, + viewportInsets: null, + ); + + double? get maybePixels; + + SheetAnchor? get maybeMinPosition; + + SheetAnchor? get maybeMaxPosition; + + Size? get maybeContentSize; + + Size? get maybeViewportSize; + + EdgeInsets? get maybeViewportInsets; + + /// The [FlutterView.devicePixelRatio] of the view that the sheet + /// associated with this metrics is drawn into. + // TODO: Move this to SheetContext. + double get devicePixelRatio; + + /// Creates a copy of the metrics with the given fields replaced. + SheetMetrics copyWith({ + double? pixels, + SheetAnchor? minPosition, + SheetAnchor? maxPosition, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + double? devicePixelRatio, + }); + + double? get maybeMinPixels => switch ((maybeMinPosition, maybeContentSize)) { + (final minPosition?, final contentSize?) => + minPosition.resolve(contentSize), + _ => null, + }; + + double? get maybeMaxPixels => switch ((maybeMaxPosition, maybeContentSize)) { + (final maxPosition?, final contentSize?) => + maxPosition.resolve(contentSize), + _ => null, + }; + + /// The current position of the sheet in pixels. + double get pixels { + assert(_debugAssertHasProperty('pixels', maybePixels)); + return maybePixels!; + } + + /// The minimum position of the sheet in pixels. + double get minPixels { + assert(_debugAssertHasProperty('minPixels', maybeMinPixels)); + return maybeMinPixels!; + } + + /// The maximum position of the sheet in pixels. + double get maxPixels { + assert(_debugAssertHasProperty('maxPixels', maybeMaxPixels)); + return maybeMaxPixels!; + } + + /// The minimum position of the sheet. + SheetAnchor get minPosition { + assert(_debugAssertHasProperty('minPosition', maybeMinPosition)); + return maybeMinPosition!; + } + + /// The maximum position of the sheet. + SheetAnchor get maxPosition { + assert(_debugAssertHasProperty('maxPosition', maybeMaxPosition)); + return maybeMaxPosition!; + } + + /// The size of the sheet's content. + Size get contentSize { + assert(_debugAssertHasProperty('contentSize', maybeContentSize)); + return maybeContentSize!; + } + + /// The size of the viewport that hosts the sheet. + Size get viewportSize { + assert(_debugAssertHasProperty('viewportSize', maybeViewportSize)); + return maybeViewportSize!; + } + + EdgeInsets get viewportInsets { + assert(_debugAssertHasProperty('viewportInsets', maybeViewportInsets)); + return maybeViewportInsets!; + } + + /// The visible height of the sheet measured from the bottom of the viewport. + /// + /// If the on-screen keyboard is visible, this value is the sum of + /// [pixels] and the keyboard's height. Otherwise, it is equal to [pixels]. + double get viewPixels => pixels + viewportInsets.bottom; + + double? get maybeViewPixels => hasDimensions ? viewPixels : null; + + /// The minimum visible height of the sheet measured from the bottom + /// of the viewport. + double get minViewPixels => minPixels + viewportInsets.bottom; + + double? get maybeMinViewPixels => hasDimensions ? minViewPixels : null; + + /// The maximum visible height of the sheet measured from the bottom + /// of the viewport. + double get maxViewPixels => maxPixels + viewportInsets.bottom; + + double? get maybeMaxViewPixels => hasDimensions ? maxViewPixels : null; + + /// Whether the all metrics are available. + /// + /// Returns true if all of [maybePixels], [maybeMinPixels], [maybeMaxPixels], + /// [maybeContentSize], [maybeViewportSize], and [maybeViewportInsets] are not + /// null. + bool get hasDimensions => + maybePixels != null && + maybeMinPosition != null && + maybeMaxPosition != null && + maybeContentSize != null && + maybeViewportSize != null && + maybeViewportInsets != null; + + /// Whether the sheet is within the range of [minPixels] and [maxPixels] + /// (inclusive of both bounds). + bool get isPixelsInBounds => + hasDimensions && + FloatComp.distance(devicePixelRatio) + .isInBounds(pixels, minPixels, maxPixels); + + /// Whether the sheet is outside the range of [minPixels] and [maxPixels]. + bool get isPixelsOutOfBounds => !isPixelsInBounds; + + bool _debugAssertHasProperty(String name, Object? value) { + assert(() { + if (value == null) { + throw FlutterError( + '$runtimeType.$name cannot be accessed before the value is set. ' + 'Consider using the corresponding $runtimeType.maybe* getter ' + 'to handle the case when the value is null. $runtimeType.hasPixels ' + 'is also useful to check if all the metrics values are set ' + 'before accessing them.', + ); + } + return true; + }()); + return true; + } +} + +/// An immutable snapshot of the state of a sheet. +class SheetMetricsSnapshot with SheetMetrics { + /// Creates an immutable snapshot of the state of a sheet. + const SheetMetricsSnapshot({ + required double? pixels, + required SheetAnchor? minPosition, + required SheetAnchor? maxPosition, + required Size? contentSize, + required Size? viewportSize, + required EdgeInsets? viewportInsets, + this.devicePixelRatio = 1.0, + }) : maybePixels = pixels, + maybeMinPosition = minPosition, + maybeMaxPosition = maxPosition, + maybeContentSize = contentSize, + maybeViewportSize = viewportSize, + maybeViewportInsets = viewportInsets; + + @override + final double? maybePixels; + + @override + final SheetAnchor? maybeMinPosition; + + @override + final SheetAnchor? maybeMaxPosition; + + @override + final Size? maybeContentSize; + + @override + final Size? maybeViewportSize; + + @override + final EdgeInsets? maybeViewportInsets; + + @override + final double devicePixelRatio; + + @override + SheetMetricsSnapshot copyWith({ + double? pixels, + SheetAnchor? minPosition, + SheetAnchor? maxPosition, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + double? devicePixelRatio, + }) { + return SheetMetricsSnapshot( + pixels: pixels ?? maybePixels, + minPosition: minPosition ?? maybeMinPosition, + maxPosition: maxPosition ?? maybeMaxPosition, + contentSize: contentSize ?? maybeContentSize, + viewportSize: viewportSize ?? maybeViewportSize, + viewportInsets: viewportInsets ?? maybeViewportInsets, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SheetMetrics && + runtimeType == other.runtimeType && + maybePixels == other.maybePixels && + maybeMinPosition == other.maybeMinPosition && + maybeMaxPosition == other.maybeMaxPosition && + maybeContentSize == other.maybeContentSize && + maybeViewportSize == other.maybeViewportSize && + maybeViewportInsets == other.maybeViewportInsets && + devicePixelRatio == other.devicePixelRatio); + + @override + int get hashCode => Object.hash( + runtimeType, + maybePixels, + maybeMinPosition, + maybeMaxPosition, + maybeContentSize, + maybeViewportSize, + maybeViewportInsets, + devicePixelRatio, + ); + + @override + String toString() => ( + hasPixels: hasDimensions, + pixels: maybePixels, + minPixels: maybeMinPixels, + maxPixels: maybeMaxPixels, + viewPixels: maybeViewPixels, + minViewPixels: maybeMinViewPixels, + maxViewPixels: maybeMaxViewPixels, + minPosition: maybeMinPosition, + maxPosition: maybeMaxPosition, + contentSize: maybeContentSize, + viewportSize: maybeViewportSize, + viewportInsets: maybeViewportInsets, + devicePixelRatio: devicePixelRatio, + ).toString(); +} diff --git a/package/lib/src/foundation/extent_driven_animation.dart b/lib/src/foundation/sheet_position_driven_animation.dart similarity index 73% rename from package/lib/src/foundation/extent_driven_animation.dart rename to lib/src/foundation/sheet_position_driven_animation.dart index cbd81dce..07126fea 100644 --- a/package/lib/src/foundation/extent_driven_animation.dart +++ b/lib/src/foundation/sheet_position_driven_animation.dart @@ -1,21 +1,21 @@ import 'package:flutter/animation.dart'; import 'sheet_controller.dart'; -import 'sheet_extent.dart'; +import 'sheet_position.dart'; -class ExtentDrivenAnimation extends Animation { - ExtentDrivenAnimation({ +class SheetPositionDrivenAnimation extends Animation { + SheetPositionDrivenAnimation({ required SheetController controller, required this.initialValue, - this.startExtent, - this.endExtent, + this.startPosition, + this.endPosition, }) : _controller = controller, assert(initialValue >= 0.0 && initialValue <= 1.0); final SheetController _controller; final double initialValue; - final Extent? startExtent; - final Extent? endExtent; + final SheetAnchor? startPosition; + final SheetAnchor? endPosition; @override void addListener(VoidCallback listener) { @@ -42,15 +42,15 @@ class ExtentDrivenAnimation extends Animation { @override double get value { - final metrics = _controller.value; + final metrics = _controller.metrics; if (!metrics.hasDimensions) { return initialValue; } final startPixels = - startExtent?.resolve(metrics.contentSize) ?? metrics.minPixels; + startPosition?.resolve(metrics.contentSize) ?? metrics.minPixels; final endPixels = - endExtent?.resolve(metrics.contentSize) ?? metrics.maxPixels; + endPosition?.resolve(metrics.contentSize) ?? metrics.maxPixels; final distance = endPixels - startPixels; if (distance.isFinite && distance > 0) { diff --git a/lib/src/foundation/sheet_position_scope.dart b/lib/src/foundation/sheet_position_scope.dart new file mode 100644 index 00000000..44eed563 --- /dev/null +++ b/lib/src/foundation/sheet_position_scope.dart @@ -0,0 +1,273 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import 'sheet_context.dart'; +import 'sheet_controller.dart'; +import 'sheet_gesture_tamperer.dart'; +import 'sheet_physics.dart'; +import 'sheet_position.dart'; + +@internal +@optionalTypeArgs +class SheetPositionScopeKey + extends LabeledGlobalKey { + SheetPositionScopeKey({String? debugLabel}) : super(debugLabel); + + final List _onCreatedListeners = []; + + T? get maybeCurrentPosition => switch (currentState?._position) { + final T position => position, + _ => null + }; + + T get currentPosition => maybeCurrentPosition!; + + void addOnCreatedListener(VoidCallback listener) { + _onCreatedListeners.add(listener); + // Immediately notify the listener if the position is already created. + if (maybeCurrentPosition != null) { + listener(); + } + } + + void removeOnCreatedListener(VoidCallback listener) { + _onCreatedListeners.remove(listener); + } + + void _notifySheetPositionCreation() { + for (final listener in _onCreatedListeners) { + listener(); + } + } + + @mustCallSuper + void dispose() { + _onCreatedListeners.clear(); + } +} + +/// A widget that creates a [SheetPosition], manages its lifecycle, +/// and exposes it to the descendant widgets. +@internal +@optionalTypeArgs +abstract class SheetPositionScope extends StatefulWidget { + /// Creates a widget that hosts a [SheetPosition]. + const SheetPositionScope({ + super.key, + required this.context, + this.controller, + this.isPrimary = true, + required this.minPosition, + required this.maxPosition, + required this.physics, + this.gestureTamperer, + required this.child, + }); + + /// The context the position object belongs to. + final SheetContext context; + + /// The [SheetController] attached to the [SheetPosition]. + final SheetController? controller; + + /// {@macro SheetPosition.minPosition} + final SheetAnchor minPosition; + + /// {@macro SheetPosition.maxPosition} + final SheetAnchor maxPosition; + + /// {@macro SheetPosition.physics} + final SheetPhysics physics; + + /// {@macro SheetPosition.gestureTamperer} + final SheetGestureProxyMixin? gestureTamperer; + + // TODO: Remove this. Specifying null to `controller` is sufficient. + final bool isPrimary; + + /// The widget below this widget in the tree. + final Widget child; + + @override + SheetPositionScopeState createState(); + + /// Retrieves a [SheetPosition] from the closest [SheetPositionScope] + /// that encloses the given context, if any. + // TODO: Add 'useRoot' option. + static E? maybeOf(BuildContext context) { + final inherited = context + .dependOnInheritedWidgetOfExactType() + ?.position; + + return inherited is E ? inherited : null; + } + + /// Retrieves a [SheetPosition] from the closest [SheetPositionScope] + /// that encloses the given context. + static E of(BuildContext context) { + final position = maybeOf(context); + + assert(() { + if (position == null) { + throw FlutterError( + 'No $SheetPositionScope ancestor for $E could be found starting ' + 'from the context that was passed to $SheetPositionScope.of(). ' + 'The context used was:\n' + '$context', + ); + } + return true; + }()); + + return position!; + } +} + +@internal +abstract class SheetPositionScopeState extends State { + late E _position; + SheetController? _controller; + + SheetPositionScopeKey? get _scopeKey { + assert(() { + if (widget.key != null && widget.key is! SheetPositionScopeKey) { + throw FlutterError( + 'The key for a SheetPositionScope<$E> must be a ' + 'SheetPositionScopeKey<$E>, but got a ${widget.key.runtimeType}.', + ); + } + return true; + }()); + + return switch (widget.key) { + final SheetPositionScopeKey key => key, + _ => null, + }; + } + + @override + void initState() { + super.initState(); + _position = buildPosition(widget.context); + _scopeKey?._notifySheetPositionCreation(); + } + + @override + void dispose() { + _disposePosition(_position); + _controller = null; + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _rewireControllerAndScope(); + _rewireControllerAndPosition(); + } + + @override + void didUpdateWidget(W oldWidget) { + super.didUpdateWidget(oldWidget); + _rewireControllerAndScope(); + if (shouldRebuildPosition(_position)) { + final oldPosition = _position; + _position = buildPosition(widget.context)..takeOver(oldPosition); + _scopeKey?._notifySheetPositionCreation(); + _disposePosition(oldPosition); + _rewireControllerAndPosition(); + } + if (_position.minPosition != widget.minPosition || + _position.maxPosition != widget.maxPosition) { + _position.applyNewBoundaryConstraints( + widget.minPosition, widget.maxPosition); + } + if (_position.physics != widget.physics) { + _position.updatePhysics(widget.physics); + } + if (_position.gestureTamperer != widget.gestureTamperer) { + _position.updateGestureTamperer(widget.gestureTamperer); + } + } + + @factory + @protected + E buildPosition(SheetContext context); + + @protected + @mustCallSuper + bool shouldRebuildPosition(E oldPosition) => + widget.context != oldPosition.context; + + void _disposePosition(E position) { + _controller?.detach(position); + position.dispose(); + } + + void _rewireControllerAndScope() { + if (_controller != widget.controller) { + _controller?.detach(_position); + _controller = widget.controller?..attach(_position); + } + } + + void _rewireControllerAndPosition() { + assert(_debugAssertPrimaryScopeNotNested()); + if (widget.isPrimary) { + _controller?.attach(_position); + } else { + _controller?.detach(_position); + } + } + + bool _debugAssertPrimaryScopeNotNested() { + assert( + () { + final parentScope = context + .dependOnInheritedWidgetOfExactType(); + if (!widget.isPrimary || + parentScope == null || + !parentScope.isPrimary) { + return true; + } + + throw FlutterError( + 'Nesting $SheetPositionScope widgets that are marked as primary ' + 'is not allowed. Typically, this error occurs when you try to nest ' + 'sheet widgets such as DraggableSheet or ScrollableSheet.', + ); + }(), + ); + + return true; + } + + @override + Widget build(BuildContext context) { + return InheritedSheetPositionScope( + position: _position, + isPrimary: widget.isPrimary, + child: widget.child, + ); + } +} + +@internal +class InheritedSheetPositionScope extends InheritedWidget { + const InheritedSheetPositionScope({ + super.key, + required this.position, + required this.isPrimary, + required super.child, + }); + + final SheetPosition position; + final bool isPrimary; + + @override + bool updateShouldNotify(InheritedSheetPositionScope oldWidget) => + position != oldWidget.position || isPrimary != oldWidget.isPrimary; +} diff --git a/package/lib/src/foundation/sheet_status.dart b/lib/src/foundation/sheet_status.dart similarity index 86% rename from package/lib/src/foundation/sheet_status.dart rename to lib/src/foundation/sheet_status.dart index 6bd89a88..cdbeb690 100644 --- a/package/lib/src/foundation/sheet_status.dart +++ b/lib/src/foundation/sheet_status.dart @@ -1,3 +1,4 @@ +// TODO: Consider removing this API. /// The status of a sheet. enum SheetStatus { /// The sheet is resting at a natural position. diff --git a/package/lib/src/foundation/sheet_theme.dart b/lib/src/foundation/sheet_theme.dart similarity index 100% rename from package/lib/src/foundation/sheet_theme.dart rename to lib/src/foundation/sheet_theme.dart diff --git a/package/lib/src/foundation/sheet_viewport.dart b/lib/src/foundation/sheet_viewport.dart similarity index 70% rename from package/lib/src/foundation/sheet_viewport.dart rename to lib/src/foundation/sheet_viewport.dart index 98063d09..d0e5945f 100644 --- a/package/lib/src/foundation/sheet_viewport.dart +++ b/lib/src/foundation/sheet_viewport.dart @@ -4,7 +4,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'sheet_extent.dart'; +import 'sheet_position.dart'; +import 'sheet_position_scope.dart'; @internal class SheetViewport extends SingleChildRenderObjectWidget { @@ -16,7 +17,7 @@ class SheetViewport extends SingleChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { return RenderSheetViewport( - SheetExtentScope.of(context), + SheetPositionScope.of(context), MediaQuery.viewInsetsOf(context), ); } @@ -24,37 +25,41 @@ class SheetViewport extends SingleChildRenderObjectWidget { @override void updateRenderObject(BuildContext context, RenderObject renderObject) { (renderObject as RenderSheetViewport) - ..extent = SheetExtentScope.of(context) + ..position = SheetPositionScope.of(context) ..insets = MediaQuery.viewInsetsOf(context); } } @internal class RenderSheetViewport extends RenderTransform { - RenderSheetViewport(SheetExtent extent, EdgeInsets insets) - : _extent = extent, + RenderSheetViewport(SheetPosition position, EdgeInsets insets) + : _position = position, _insets = insets, super(transform: Matrix4.zero(), transformHitTests: true) { - _extent.addListener(_invalidateTranslationValue); + _position.addListener(_invalidateTranslationValue); } // Cache the last measured size because we can't access // 'size' property from outside of the layout phase. Size? _lastMeasuredSize; + Size? get lastMeasuredSize => _lastMeasuredSize; - SheetExtent _extent; + SheetPosition _position; + // ignore: avoid_setters_without_getters - set extent(SheetExtent value) { - if (_extent != value) { - _extent.removeListener(_invalidateTranslationValue); - _extent = value..addListener(_invalidateTranslationValue); + set position(SheetPosition value) { + if (_position != value) { + _position.removeListener(_invalidateTranslationValue); + _position = value..addListener(_invalidateTranslationValue); markNeedsLayout(); } } EdgeInsets _insets; + EdgeInsets get insets => _insets; + set insets(EdgeInsets value) { if (value != _insets) { _insets = value; @@ -66,11 +71,11 @@ class RenderSheetViewport extends RenderTransform { void performLayout() { // We can assume that the viewport will always be as big as possible. _lastMeasuredSize = constraints.biggest; - _extent.markAsDimensionsWillChange(); - // Notify the SheetExtent about the viewport size changes + _position.markAsDimensionsWillChange(); + // Notify the SheetPosition about the viewport size changes // before performing the layout so that the descendant widgets // can use the viewport size during the layout phase. - _extent.applyNewViewportDimensions( + _position.applyNewViewportDimensions( Size(_lastMeasuredSize!.width, _lastMeasuredSize!.height), _insets, ); @@ -84,12 +89,12 @@ class RenderSheetViewport extends RenderTransform { ); assert( - _extent.metrics.hasDimensions, - 'The sheet extent and the dimensions values ' + _position.hasDimensions, + 'The sheet position and the dimensions values ' 'must be finalized during the layout phase.', ); - _extent.markAsDimensionsChanged(); + _position.markAsDimensionsChanged(); _invalidateTranslationValue(); } @@ -100,19 +105,42 @@ class RenderSheetViewport extends RenderTransform { } void _invalidateTranslationValue() { - final currentExtent = _extent.metrics.maybePixels; + final currentPosition = _position.maybePixels; final viewportSize = _lastMeasuredSize; - if (currentExtent != null && viewportSize != null) { - final dy = viewportSize.height - _insets.bottom - currentExtent; + if (currentPosition != null && viewportSize != null) { + final dy = viewportSize.height - _insets.bottom - currentPosition; // Update the translation value and mark this render object // as needing to be repainted. transform = Matrix4.translationValues(0, dy, 0); } } + // Mirrors `super._transform` as there is no public getter for it. + // This should be initialized before the first call to hitTest(). + late Matrix4 _transform; + + @override + set transform(Matrix4 value) { + super.transform = value; + _transform = value; + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (_position.activity.shouldIgnorePointer) { + final invTransform = Matrix4.tryInvert( + PointerEvent.removePerspectiveTransform(_transform), + ); + return invTransform != null && + size.contains(MatrixUtils.transformPoint(invTransform, position)); + } + + return super.hitTest(result, position: position); + } + @override void dispose() { - _extent.removeListener(_invalidateTranslationValue); + _position.removeListener(_invalidateTranslationValue); super.dispose(); } } @@ -160,7 +188,7 @@ class _SheetContentViewportState extends State { state: this, child: _SheetContentLayoutObserver( isEnabled: _isEnabled, - extent: SheetExtentScope.maybeOf(context), + position: SheetPositionScope.maybeOf(context), child: widget.child, ), ); @@ -188,17 +216,17 @@ class _SheetContentViewportScope extends InheritedWidget { class _SheetContentLayoutObserver extends SingleChildRenderObjectWidget { const _SheetContentLayoutObserver({ required this.isEnabled, - required this.extent, + required this.position, required super.child, }); final ValueGetter isEnabled; - final SheetExtent? extent; + final SheetPosition? position; @override RenderObject createRenderObject(BuildContext context) { return _RenderSheetContentLayoutObserver( - extent: extent, + position: position, isEnabled: isEnabled, ); } @@ -206,7 +234,7 @@ class _SheetContentLayoutObserver extends SingleChildRenderObjectWidget { @override void updateRenderObject(BuildContext context, RenderObject renderObject) { (renderObject as _RenderSheetContentLayoutObserver) - ..extent = extent + ..position = position ..isEnabled = isEnabled; } } @@ -214,21 +242,23 @@ class _SheetContentLayoutObserver extends SingleChildRenderObjectWidget { class _RenderSheetContentLayoutObserver extends RenderPositionedBox { _RenderSheetContentLayoutObserver({ required ValueGetter isEnabled, - required SheetExtent? extent, + required SheetPosition? position, }) : _isEnabled = isEnabled, - _extent = extent, + _position = position, super(alignment: Alignment.topCenter); - SheetExtent? _extent; + SheetPosition? _position; + // ignore: avoid_setters_without_getters - set extent(SheetExtent? value) { - if (_extent != value) { - _extent = value; + set position(SheetPosition? value) { + if (_position != value) { + _position = value; markNeedsLayout(); } } ValueGetter _isEnabled; + // ignore: avoid_setters_without_getters set isEnabled(ValueGetter value) { if (_isEnabled != value) { @@ -239,7 +269,7 @@ class _RenderSheetContentLayoutObserver extends RenderPositionedBox { @override void performLayout() { - _extent?.markAsDimensionsWillChange(); + _position?.markAsDimensionsWillChange(); super.performLayout(); final childSize = child?.size; // The evaluation of _isEnabled() is intentionally delayed @@ -248,8 +278,8 @@ class _RenderSheetContentLayoutObserver extends RenderPositionedBox { // and if another SheetContentViewport exists in the subtree, // it will change the result of _isEnabled(). if (_isEnabled() && childSize != null) { - _extent?.applyNewContentSize(childSize); + _position?.applyNewContentSize(childSize); } - _extent?.markAsDimensionsChanged(); + _position?.markAsDimensionsChanged(); } } diff --git a/lib/src/internal/double_utils.dart b/lib/src/internal/double_utils.dart new file mode 100644 index 00000000..676213fa --- /dev/null +++ b/lib/src/internal/double_utils.dart @@ -0,0 +1,12 @@ +import 'dart:math'; + +extension DoubleUtils on double { + double clampAbs(double norm) => min(max(-norm, this), norm); + + double nearest(double a, double b) => + (a - this).abs() < (b - this).abs() ? a : b; + + double inverseLerp(double min, double max) { + return min == max ? 1.0 : (this - min) / (max - min); + } +} diff --git a/lib/src/internal/float_comp.dart b/lib/src/internal/float_comp.dart new file mode 100644 index 00000000..fabb4115 --- /dev/null +++ b/lib/src/internal/float_comp.dart @@ -0,0 +1,99 @@ +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// Caches [FloatComp] instances for different epsilon values to avoid +/// object creations for every comparison. Although these instances may never +/// be released, the memory overhead is negligible as the device pixel ratio +/// rarely changes during the app's lifetime. +final _instanceForEpsilon = {}; + +// TODO: Reimplement this class as an extension type of [double] +// to avoid object creation. +/// A comparator for floating-point numbers in a certain precision. +/// +/// [FloatComp.distance] and [FloatComp.velocity] determine the [epsilon] based +/// on the given device pixel ratio, which is the number of physical pixels per +/// logical pixel. +@internal +class FloatComp { + /// Creates a [FloatComp] with the given [epsilon]. + factory FloatComp({required double epsilon}) { + return _instanceForEpsilon[epsilon] ??= FloatComp._(epsilon); + } + + /// Creates a [FloatComp] for comparing distances. + /// + /// The [devicePixelRatio] is the number of physical pixels per logical + /// pixel. This is typically obtained by [MediaQuery.devicePixelRatioOf]. + factory FloatComp.distance(double devicePixelRatio) { + return FloatComp(epsilon: 1e-3 / devicePixelRatio); + } + + /// Creates a [FloatComp] for comparing velocities. + /// + /// The [devicePixelRatio] is the number of physical pixels per logical + /// pixel. This is typically obtained by [MediaQuery.devicePixelRatioOf]. + factory FloatComp.velocity(double devicePixelRatio) { + return FloatComp(epsilon: 1e-4 / devicePixelRatio); + } + + const FloatComp._(this.epsilon); + + /// The maximum difference between two floating-point numbers to consider + /// them approximately equal. + final double epsilon; + + /// Returns `true` if [a] is approximately equal to [b]. + bool isApprox(double a, double b) => nearEqual(a, b, epsilon); + + /// Returns `true` if [a] is not approximately equal to [b]. + bool isNotApprox(double a, double b) => !isApprox(a, b); + + /// Returns `true` if [a] is less than [b] and not approximately equal to [b]. + bool isLessThan(double a, double b) => a < b && !isApprox(a, b); + + /// Returns `true` if [a] is greater than [b] and not approximately + /// equal to [b]. + bool isGreaterThan(double a, double b) => a > b && !isApprox(a, b); + + /// Returns `true` if [a] is less than [b] or approximately equal to [b]. + bool isLessThanOrApprox(double a, double b) => + isLessThan(a, b) || isApprox(a, b); + + /// Returns `true` if [a] is greater than [b] or approximately equal to [b]. + bool isGreaterThanOrApprox(double a, double b) => + isGreaterThan(a, b) || isApprox(a, b); + + /// Returns `true` if [a] is less than [min] or greater than [max]. + bool isOutOfBounds(double a, double min, double max) => + isLessThan(a, min) || isGreaterThan(a, max); + + /// Returns `true` if [a] is out of bounds or approximately equal to [min] + /// or [max]. + bool isOutOfBoundsOrApprox(double a, double min, double max) => + isOutOfBounds(a, min, max) || isApprox(a, min) || isApprox(a, max); + + /// Returns `true` if [a] is in the range `[min, max]`, inclusive. + bool isInBounds(double a, double min, double max) => + !isOutOfBounds(a, min, max); + + /// Returns `true` if [a] is in the range `(min, max)`, exclusive. + bool isInBoundsExclusive(double a, double min, double max) => + isGreaterThan(a, min) && isLessThan(a, max); + + /// Returns [b] if [a] is approximately equal to [b], otherwise [a]. + double roundToIfApprox(double a, double b) => isApprox(a, b) ? b : a; + + /// Rounds the given values to the nearest edge value if + /// they are approximately equal. + /// + /// If `a` is approximately equal to `b`, returns `b`. + /// If `a` is approximately equal to `c`, returns `c`. + /// Otherwise, returns `a`. + double roundToEdgeIfApprox(double a, double b, double c) { + if (isApprox(a, b)) return b; + if (isApprox(a, c)) return c; + return a; + } +} diff --git a/package/lib/src/internal/monodrag.dart b/lib/src/internal/monodrag.dart similarity index 100% rename from package/lib/src/internal/monodrag.dart rename to lib/src/internal/monodrag.dart diff --git a/package/lib/src/internal/transition_observer.dart b/lib/src/internal/transition_observer.dart similarity index 77% rename from package/lib/src/internal/transition_observer.dart rename to lib/src/internal/transition_observer.dart index 620d1a7c..445845f1 100644 --- a/package/lib/src/internal/transition_observer.dart +++ b/lib/src/internal/transition_observer.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class TransitionObserver extends NavigatorObserver { @@ -167,15 +168,8 @@ class NoTransition extends Transition { final ModalRoute currentRoute; @override - bool operator ==(Object other) { - return identical(this, other) || - (other is NoTransition && - runtimeType == other.runtimeType && - currentRoute == other.currentRoute); - } - - @override - int get hashCode => Object.hash(runtimeType, currentRoute); + String toString() => + '$NoTransition${(currentRoute: describeIdentity(currentRoute))}'; } class ForwardTransition extends Transition { @@ -190,22 +184,10 @@ class ForwardTransition extends Transition { final Animation animation; @override - bool operator ==(Object other) { - return identical(this, other) || - (other is ForwardTransition && - runtimeType == other.runtimeType && - originRoute == other.originRoute && - destinationRoute == other.destinationRoute && - animation == other.animation); - } - - @override - int get hashCode => Object.hash( - runtimeType, - originRoute, - destinationRoute, - animation, - ); + String toString() => '$ForwardTransition${( + originRoute: describeIdentity(originRoute), + destinationRoute: describeIdentity(destinationRoute), + )}'; } class BackwardTransition extends Transition { @@ -220,22 +202,10 @@ class BackwardTransition extends Transition { final Animation animation; @override - bool operator ==(Object other) { - return identical(this, other) || - (other is BackwardTransition && - runtimeType == other.runtimeType && - originRoute == other.originRoute && - destinationRoute == other.destinationRoute && - animation == other.animation); - } - - @override - int get hashCode => Object.hash( - runtimeType, - originRoute, - destinationRoute, - animation, - ); + String toString() => '$BackwardTransition${( + originRoute: describeIdentity(originRoute), + destinationRoute: describeIdentity(destinationRoute), + )}'; } class UserGestureTransition extends Transition { @@ -250,20 +220,8 @@ class UserGestureTransition extends Transition { final Animation animation; @override - bool operator ==(Object other) { - return identical(this, other) || - (other is UserGestureTransition && - runtimeType == other.runtimeType && - currentRoute == other.currentRoute && - previousRoute == other.previousRoute && - animation == other.animation); - } - - @override - int get hashCode => Object.hash( - runtimeType, - currentRoute, - previousRoute, - animation, - ); + String toString() => '$UserGestureTransition${( + currentRoute: describeIdentity(currentRoute), + previousRoute: describeIdentity(previousRoute), + )}'; } diff --git a/package/lib/src/modal/cupertino.dart b/lib/src/modal/cupertino.dart similarity index 96% rename from package/lib/src/modal/cupertino.dart rename to lib/src/modal/cupertino.dart index e82f6508..f206bb3f 100644 --- a/package/lib/src/modal/cupertino.dart +++ b/lib/src/modal/cupertino.dart @@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart'; import '../foundation/sheet_controller.dart'; import '../internal/double_utils.dart'; import 'modal_sheet.dart'; +import 'swipe_dismiss_sensitivity.dart'; const _minimizedViewportScale = 0.92; const _cupertinoBarrierColor = Color(0x18000000); @@ -372,15 +373,14 @@ abstract class _BaseCupertinoModalSheetRoute extends PageRoute switch (controller!.status) { case AnimationStatus.forward: case AnimationStatus.completed: - final metrics = _sheetController.value; + final metrics = _sheetController.metrics; if (metrics.hasDimensions) { _cupertinoTransitionControllerOf[_previousRoute]?.value = min( controller!.value, - inverseLerp( + metrics.viewPixels.inverseLerp( // TODO: Make this configurable. metrics.viewportSize.height / 2, metrics.viewportSize.height, - metrics.viewPixels, ), ); } @@ -424,6 +424,7 @@ class CupertinoModalSheetPage extends Page { this.transitionDuration = _cupertinoTransitionDuration, this.transitionCurve = _cupertinoTransitionCurve, this.allowTapBehindModalPage = false, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), required this.child, }); @@ -449,6 +450,8 @@ class CupertinoModalSheetPage extends Page { final bool allowTapBehindModalPage; + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Route createRoute(BuildContext context) { return _PageBasedCupertinoModalSheetRoute( @@ -489,6 +492,10 @@ class _PageBasedCupertinoModalSheetRoute @override Duration get transitionDuration => _page.transitionDuration; + @override + SwipeDismissSensitivity get swipeDismissSensitivity => + _page.swipeDismissSensitivity; + @override String get debugLabel => '${super.debugLabel}(${_page.name})'; @@ -512,6 +519,7 @@ class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute { this.transitionDuration = _cupertinoTransitionDuration, this.transitionCurve = _cupertinoTransitionCurve, this.allowTapBehindModalPage = false, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), }); final WidgetBuilder builder; @@ -540,6 +548,9 @@ class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute { @override final bool allowTapBehindModalPage; + @override + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Widget buildContent(BuildContext context) { return builder(context); diff --git a/package/lib/src/modal/modal.dart b/lib/src/modal/modal.dart similarity index 79% rename from package/lib/src/modal/modal.dart rename to lib/src/modal/modal.dart index 8c4aad49..d0b80088 100644 --- a/package/lib/src/modal/modal.dart +++ b/lib/src/modal/modal.dart @@ -6,3 +6,4 @@ export 'cupertino.dart' CupertinoStackedTransition; export 'modal_sheet.dart' show ModalSheetPage, ModalSheetRoute, ModalSheetRouteMixin; +export 'swipe_dismiss_sensitivity.dart' show SwipeDismissSensitivity; diff --git a/package/lib/src/modal/modal_sheet.dart b/lib/src/modal/modal_sheet.dart similarity index 69% rename from package/lib/src/modal/modal_sheet.dart rename to lib/src/modal/modal_sheet.dart index a55181cb..7add9ff9 100644 --- a/package/lib/src/modal/modal_sheet.dart +++ b/lib/src/modal/modal_sheet.dart @@ -1,14 +1,14 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../foundation/sheet_drag.dart'; import '../foundation/sheet_gesture_tamperer.dart'; -import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; +import 'swipe_dismiss_sensitivity.dart'; -const _minFlingVelocityToDismiss = 1.0; -const _minDragDistanceToDismiss = 100.0; // Logical pixels. const _minReleasedPageForwardAnimationTime = 300; // Milliseconds. const _releasedPageForwardAnimationCurve = Curves.fastLinearToSlowEaseIn; @@ -27,6 +27,7 @@ class ModalSheetPage extends Page { this.transitionDuration = const Duration(milliseconds: 300), this.transitionCurve = Curves.fastEaseInToSlowEaseOut, this.allowTapBehindModalPage = false, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), required this.child, }); @@ -52,6 +53,8 @@ class ModalSheetPage extends Page { final bool allowTapBehindModalPage; + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Route createRoute(BuildContext context) { return _PageBasedModalSheetRoute( @@ -91,6 +94,10 @@ class _PageBasedModalSheetRoute extends PageRoute @override Duration get transitionDuration => _page.transitionDuration; + @override + SwipeDismissSensitivity get swipeDismissSensitivity => + _page.swipeDismissSensitivity; + @override String get debugLabel => '${super.debugLabel}(${_page.name})'; @@ -114,6 +121,7 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { this.transitionDuration = const Duration(milliseconds: 300), this.transitionCurve = Curves.fastEaseInToSlowEaseOut, this.allowTapBehindModalPage = false, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), }); final WidgetBuilder builder; @@ -142,6 +150,9 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { @override final bool allowTapBehindModalPage; + @override + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Widget buildContent(BuildContext context) { return builder(context); @@ -150,16 +161,30 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { mixin ModalSheetRouteMixin on ModalRoute { bool get swipeDismissible; + Curve get transitionCurve; bool get allowTapBehindModalPage; + SwipeDismissSensitivity get swipeDismissSensitivity; + @override bool get opaque => false; + /// The curve used for the transition animation. + /// + /// In the middle of a dismiss gesture drag, + /// this returns [Curves.linear] to match the finger motion. + @nonVirtual + @visibleForTesting + Curve get effectiveCurve => (navigator?.userGestureInProgress ?? false) + ? Curves.linear + : transitionCurve; + /// Lazily initialized in case `swipeDismissible` is set to false. late final _swipeDismissibleController = _SwipeDismissibleController( route: this, transitionController: controller!, + sensitivity: swipeDismissSensitivity, ); Widget buildContent(BuildContext context); @@ -171,7 +196,7 @@ mixin ModalSheetRouteMixin on ModalRoute { Animation secondaryAnimation, ) { return switch (swipeDismissible) { - true => TamperSheetGesture( + true => SheetGestureProxy( tamperer: _swipeDismissibleController, child: buildContent(context), ), @@ -187,13 +212,9 @@ mixin ModalSheetRouteMixin on ModalRoute { Widget child, ) { final transitionTween = Tween(begin: const Offset(0, 1), end: Offset.zero); - // In the middle of a dismiss gesture drag, - // let the transition be linear to match finger motions. - final curve = - navigator!.userGestureInProgress ? Curves.linear : this.transitionCurve; return SlideTransition( position: animation.drive( - transitionTween.chain(CurveTween(curve: curve)), + transitionTween.chain(CurveTween(curve: effectiveCurve)), ), child: child, ); @@ -238,14 +259,16 @@ mixin ModalSheetRouteMixin on ModalRoute { } } -class _SwipeDismissibleController with SheetGestureTamperer { +class _SwipeDismissibleController with SheetGestureProxyMixin { _SwipeDismissibleController({ required this.route, required this.transitionController, + required this.sensitivity, }); final ModalRoute route; final AnimationController transitionController; + final SwipeDismissSensitivity sensitivity; BuildContext get _context => route.subtreeContext!; @@ -260,7 +283,7 @@ class _SwipeDismissibleController with SheetGestureTamperer { } @override - SheetDragUpdateDetails tamperWithDragUpdate( + SheetDragUpdateDetails onDragUpdate( SheetDragUpdateDetails details, Offset minPotentialDeltaConsumption, ) { @@ -278,7 +301,8 @@ class _SwipeDismissibleController with SheetGestureTamperer { // Dominantly use the full pixels if it is in the middle of a transition. effectiveDragDelta = dragDelta; } else if (dragDelta < 0 && - !dragDelta.isApprox(minPDC) && + FloatComp.distance(MediaQuery.devicePixelRatioOf(_context)) + .isNotApprox(dragDelta, minPDC) && MediaQuery.viewInsetsOf(_context).bottom == 0) { // If the drag is downwards and the sheet may not consume the full pixels, // then use the remaining pixels as the effective drag delta. @@ -289,7 +313,7 @@ class _SwipeDismissibleController with SheetGestureTamperer { assert(dragDelta * effectiveDragDelta >= 0); } else { // Otherwise, the drag delta doesn't change the transition progress. - return super.tamperWithDragUpdate(details, minPotentialDeltaConsumption); + return super.onDragUpdate(details, minPotentialDeltaConsumption); } final viewport = _context.size!.height; @@ -310,39 +334,61 @@ class _SwipeDismissibleController with SheetGestureTamperer { VerticalDirection.down => viewportDelta - dragDelta, }; - return super.tamperWithDragUpdate( + return super.onDragUpdate( details.copyWith(deltaY: unconsumedDragDelta), minPotentialDeltaConsumption, ); } @override - SheetDragEndDetails tamperWithDragEnd(SheetDragEndDetails details) { + SheetDragEndDetails onDragEnd(SheetDragEndDetails details) { + final wasHandled = _handleDragEnd( + velocity: details.velocity, + axisDirection: details.axisDirection, + ); + return wasHandled + ? super.onDragEnd(details.copyWith(velocityX: 0, velocityY: 0)) + : super.onDragEnd(details); + } + + @override + void onDragCancel(SheetDragCancelDetails details) { + super.onDragCancel(details); + _handleDragEnd( + axisDirection: details.axisDirection, + velocity: Velocity.zero, + ); + } + + bool _handleDragEnd({ + required Velocity velocity, + required VerticalDirection axisDirection, + }) { if (!_isUserGestureInProgress || transitionController.isAnimating) { - return super.tamperWithDragEnd(details); + return false; } final viewportHeight = _context.size!.height; final draggedDistance = viewportHeight * (1 - transitionController.value); - final velocity = switch (details.axisDirection) { - VerticalDirection.up => - details.velocity.pixelsPerSecond.dy / viewportHeight, + final effectiveVelocity = switch (axisDirection) { + VerticalDirection.up => velocity.pixelsPerSecond.dy / viewportHeight, VerticalDirection.down => - -1 * details.velocity.pixelsPerSecond.dy / viewportHeight, + -1 * velocity.pixelsPerSecond.dy / viewportHeight, }; final bool invokePop; if (MediaQuery.viewInsetsOf(_context).bottom > 0) { // The on-screen keyboard is open. invokePop = false; - } else if (velocity < 0) { + } else if (effectiveVelocity < 0) { // Flings down. - invokePop = velocity.abs() > _minFlingVelocityToDismiss; - } else if (velocity.isApprox(0)) { + invokePop = effectiveVelocity.abs() > sensitivity.minFlingVelocityRatio; + } else if (FloatComp.velocity(MediaQuery.devicePixelRatioOf(_context)) + .isApprox(effectiveVelocity, 0)) { assert(draggedDistance >= 0); // Dragged down enough to dismiss. - invokePop = draggedDistance > _minDragDistanceToDismiss; + invokePop = draggedDistance > sensitivity.minDragDistance; } else { // Flings up. invokePop = false; @@ -369,27 +415,49 @@ class _SwipeDismissibleController with SheetGestureTamperer { )); } - if (transitionController.isAnimating) { - // Keep the userGestureInProgress in true state so we don't change the - // curve of the page transition mid-flight since the route's transition - // depends on userGestureInProgress. + // Reset the transition animation curve back to the default from linear + // indirectly, by resetting the userGestureInProgress flag. + // It is "indirect" because ModalSheetRouteMixin.effectiveCurve returns + // the linear curve when the userGestureInProgress flag is set to true. + // + // If the transition animation has not settled at either the start or end, + // delay resetting the userGestureInProgress until the animation completes + // to ensure the effectiveCurve remains linear during the animation, + // matching the user's swipe motion. This is important to prevent the sheet + // from jerking when the user swipes it down. + // See https://github.com/fujidaiti/smooth_sheets/issues/250. + // + // Note: We cannot use AnimationController.isAnimating here to determine if + // the transition animation is running, because, in Navigator 2.0, + // the pop animation may not have started at this point even if + // Navigator.pop() is called to pop the modal route. + // + // The following sequence of events illustrates why: + // 1. Calling Navigator.pop() updates the internal page stack, triggering + // a rebuild of the Navigator. Note that the transition animation + // does not start here, so AnimationController.isAnimating returns false. + // 2. The Navigator rebuilds with the new page stack. + // 3. The modal route is removed from the Navigator's subtree. + // 4. Route.didPop() is called, initiating the pop transition animation + // by calling AnimationController.reverse(). + if (transitionController.isCompleted || transitionController.isDismissed) { + _isUserGestureInProgress = false; + } else { late final AnimationStatusListener animationStatusCallback; - animationStatusCallback = (_) { - _isUserGestureInProgress = false; - transitionController.removeStatusListener(animationStatusCallback); + animationStatusCallback = (status) { + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + _isUserGestureInProgress = false; + transitionController.removeStatusListener(animationStatusCallback); + } }; transitionController.addStatusListener(animationStatusCallback); - } else { - // Otherwise, reset the userGestureInProgress state immediately. - _isUserGestureInProgress = false; } if (invokePop) { route.onPopInvoked(didPop); } - return super.tamperWithDragEnd( - details.copyWith(velocityX: 0, velocityY: 0), - ); + return true; } } diff --git a/lib/src/modal/swipe_dismiss_sensitivity.dart b/lib/src/modal/swipe_dismiss_sensitivity.dart new file mode 100644 index 00000000..b568bc0e --- /dev/null +++ b/lib/src/modal/swipe_dismiss_sensitivity.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; + +import 'modal_sheet.dart'; + +/// Configuration for the swipe-to-dismiss sensitivity of [ModalSheetRoute], +/// [ModalSheetPage], and related classes. +/// +/// The modal will be dismissed under the following conditions: +/// - A downward fling gesture with the ratio of the velocity to the viewport +/// height that exceeds [minFlingVelocityRatio]. +/// - A drag gesture ending with zero velocity, where the downward distance +/// exceeds [minDragDistance]. +class SwipeDismissSensitivity { + /// Creates a swipe-to-dismiss sensitivity configuration. + const SwipeDismissSensitivity({ + this.minFlingVelocityRatio = 2.0, + this.minDragDistance = 200.0, + }); + + /// Minimum ratio of gesture velocity to viewport height required to + /// trigger dismissal for a downward fling gesture. + /// + /// The viewport height is obtained from the `size` property of the + /// navigator's [BuildContext] where the modal route belongs to. + /// Therefore, the larger the viewport height, the higher the velocity + /// required to dismiss the modal (and vice versa). This is to ensure that + /// the swipe-to-dismiss behavior is consistent across different screen sizes. + /// + /// As a reference, the ratio of 1.0 corresponds to the velocity such that + /// the user moves their finger from the top to the bottom of the screen + /// in exactly 1 second. + final double minFlingVelocityRatio; + + /// Minimum downward drag distance required for dismissal when the + /// gesture ends with zero velocity. + /// + /// If the drag gesture ends with a non-zero velocity, it's treated as + /// a fling gesture, and this value is not used. + final double minDragDistance; +} diff --git a/package/lib/src/navigation/navigation.dart b/lib/src/navigation/navigation.dart similarity index 74% rename from package/lib/src/navigation/navigation.dart rename to lib/src/navigation/navigation.dart index 2b1de225..f3b4c344 100644 --- a/package/lib/src/navigation/navigation.dart +++ b/lib/src/navigation/navigation.dart @@ -1,5 +1,8 @@ export 'navigation_route.dart' - show NavigationSheetRoute, NavigationSheetRouteContent; + show + NavigationSheetRoute, + NavigationSheetRouteContent, + SheetPositionScopeBuilder; export 'navigation_routes.dart' show DraggableNavigationSheetPage, diff --git a/lib/src/navigation/navigation_route.dart b/lib/src/navigation/navigation_route.dart new file mode 100644 index 00000000..bab565e0 --- /dev/null +++ b/lib/src/navigation/navigation_route.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; +import '../foundation/sheet_viewport.dart'; +import 'navigation_sheet.dart'; +import 'navigation_sheet_position.dart'; +import 'navigation_sheet_viewport.dart'; + +@optionalTypeArgs +abstract class NavigationSheetRoute + extends PageRoute { + NavigationSheetRoute({super.settings}); + + SheetPositionScopeKey get scopeKey => _scopeKey; + late final SheetPositionScopeKey _scopeKey; + + SheetPositionScopeKey createScopeKey(); + + @override + void install() { + super.install(); + assert(_debugAssertDependencies()); + _scopeKey = createScopeKey(); + } + + @override + void dispose() { + _scopeKey.dispose(); + super.dispose(); + } + + @override + void changedExternalState() { + super.changedExternalState(); + assert(_debugAssertDependencies()); + } + + bool _debugAssertDependencies() { + assert( + () { + final globalPosition = + SheetPositionScope.maybeOf( + navigator!.context); + if (globalPosition == null) { + throw FlutterError( + 'A $SheetPositionScope that hosts a $NavigationSheetPosition ' + 'is not found in the given context. This is likely because ' + 'this $NavigationSheetRoute is not a route of the navigator ' + 'enclosed by a $NavigationSheet.', + ); + } + return true; + }(), + ); + return true; + } + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + RouteTransitionsBuilder? get transitionsBuilder; + + @override + bool canTransitionFrom(TransitionRoute previousRoute) { + return previousRoute is NavigationSheetRoute; + } + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + return nextRoute is NavigationSheetRoute; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final builder = transitionsBuilder ?? _buildDefaultTransitions; + return builder(context, animation, secondaryAnimation, child); + } + + Widget _buildDefaultTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final theme = Theme.of(context).pageTransitionsTheme; + final platformAdaptiveTransitions = theme.buildTransitions( + this, context, animation, secondaryAnimation, child); + + final fadeInTween = TweenSequence([ + TweenSequenceItem(tween: ConstantTween(0.0), weight: 1), + TweenSequenceItem( + tween: Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: Curves.easeIn), + ), + weight: 1, + ), + ]); + + final fadeOutTween = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0).chain( + CurveTween(curve: Curves.easeIn), + ), + weight: 1, + ), + TweenSequenceItem(tween: ConstantTween(0.0), weight: 1), + ]); + + return FadeTransition( + opacity: animation.drive(fadeInTween), + child: FadeTransition( + opacity: secondaryAnimation.drive(fadeOutTween), + child: platformAdaptiveTransitions, + ), + ); + } +} + +typedef SheetPositionScopeBuilder = SheetPositionScope Function( + SheetContext context, + SheetPositionScopeKey key, + Widget child, +); + +class NavigationSheetRouteContent extends StatelessWidget { + const NavigationSheetRouteContent({ + super.key, + required this.scopeBuilder, + required this.child, + }); + + final SheetPositionScopeBuilder scopeBuilder; + final Widget child; + + @override + Widget build(BuildContext context) { + assert(_debugAssertDependencies(context)); + final parentRoute = ModalRoute.of(context)! as NavigationSheetRoute; + final globalPosition = + SheetPositionScope.of(context); + final routeViewport = NavigationSheetRouteViewport( + child: SheetContentViewport(child: child), + ); + final localScope = scopeBuilder( + globalPosition.context, + parentRoute.scopeKey, + routeViewport, + ); + assert(_debugAssertScope(localScope, parentRoute.scopeKey, routeViewport)); + return localScope; + } + + bool _debugAssertScope( + SheetPositionScope scope, + Key expectedKey, + Widget expectedChild, + ) { + assert(() { + if (scope.key != expectedKey) { + throw FlutterError( + 'The key of the SheetPositionScope returned by `scopeBuilder` does ' + 'not match the key passed to the builder. This is likely a mistake.', + ); + } + if (scope.child != expectedChild) { + throw FlutterError( + 'The child of the SheetPositionScope returned by `scopeBuilder` does ' + 'not match the child passed to the builder. ' + 'This is likely a mistake.', + ); + } + if (scope.controller != null) { + throw FlutterError( + 'The SheetPositionScope returned by the `scopeBuilder` should not ' + 'have a controller. Since the controller is managed by the global ' + ' scope, this is likely a mistake.', + ); + } + return true; + }()); + return true; + } + + bool _debugAssertDependencies(BuildContext context) { + assert( + () { + final globalPosition = + SheetPositionScope.maybeOf(context); + if (globalPosition == null) { + throw FlutterError( + 'A SheetPositionScope that hosts a $NavigationSheetPosition ' + 'is not found in the given context. This is likely because ' + 'this NavigationSheetRouteContent is not in the subtree of ' + 'the navigator enclosed by a NavigationSheet.', + ); + } + + final parentRoute = ModalRoute.of(context); + if (parentRoute is NavigationSheetRoute) { + return true; + } + throw FlutterError( + 'The NavigationSheetRouteContent must be the content of ' + 'a NavigationSheetRoute, but the result of ModalRoute.of(context) ' + 'is ${parentRoute?.runtimeType}.', + ); + }(), + ); + return true; + } +} diff --git a/package/lib/src/navigation/navigation_routes.dart b/lib/src/navigation/navigation_routes.dart similarity index 57% rename from package/lib/src/navigation/navigation_routes.dart rename to lib/src/navigation/navigation_routes.dart index 1dc1518b..b6a8653b 100644 --- a/package/lib/src/navigation/navigation_routes.dart +++ b/lib/src/navigation/navigation_routes.dart @@ -1,47 +1,55 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../draggable/draggable_sheet_extent.dart'; +import '../draggable/draggable_sheet_position.dart'; +import '../draggable/draggable_sheet_position_scope.dart'; import '../draggable/sheet_draggable.dart'; -import '../foundation/sheet_extent.dart'; import '../foundation/sheet_gesture_tamperer.dart'; import '../foundation/sheet_physics.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; import '../foundation/sheet_theme.dart'; import '../scrollable/scrollable_sheet.dart'; -import '../scrollable/scrollable_sheet_extent.dart'; +import '../scrollable/scrollable_sheet_position.dart'; +import '../scrollable/scrollable_sheet_position_scope.dart'; import 'navigation_route.dart'; class _ScrollableNavigationSheetRouteContent extends StatelessWidget { const _ScrollableNavigationSheetRouteContent({ this.debugLabel, - required this.initialExtent, - required this.minExtent, - required this.maxExtent, + required this.initialPosition, + required this.minPosition, + required this.maxPosition, required this.physics, required this.child, }); final String? debugLabel; - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; final Widget child; @override Widget build(BuildContext context) { final theme = SheetTheme.maybeOf(context); - final gestureTamper = TamperSheetGesture.maybeOf(context); + final gestureTamper = SheetGestureProxy.maybeOf(context); return NavigationSheetRouteContent( - factory: const ScrollableSheetExtentFactory(), - config: ScrollableSheetExtentConfig.withFallbacks( - debugLabel: debugLabel, - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, - physics: physics ?? theme?.physics, - gestureTamperer: gestureTamper, - ), + scopeBuilder: (context, key, child) { + return ScrollableSheetPositionScope( + key: key, + context: context, + isPrimary: false, + initialPosition: initialPosition, + minPosition: minPosition, + maxPosition: maxPosition, + physics: physics ?? theme?.physics ?? kDefaultSheetPhysics, + gestureTamperer: gestureTamper, + child: child, + ); + }, child: ScrollableSheetContent(child: child), ); } @@ -50,17 +58,17 @@ class _ScrollableNavigationSheetRouteContent extends StatelessWidget { class _DraggableNavigationSheetRouteContent extends StatelessWidget { const _DraggableNavigationSheetRouteContent({ this.debugLabel, - required this.initialExtent, - required this.minExtent, - required this.maxExtent, + required this.initialPosition, + required this.minPosition, + required this.maxPosition, required this.physics, required this.child, }); final String? debugLabel; - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; final Widget child; @@ -68,39 +76,45 @@ class _DraggableNavigationSheetRouteContent extends StatelessWidget { Widget build(BuildContext context) { final theme = SheetTheme.maybeOf(context); final physics = this.physics ?? theme?.physics ?? kDefaultSheetPhysics; - final gestureTamper = TamperSheetGesture.maybeOf(context); + final gestureTamper = SheetGestureProxy.maybeOf(context); return NavigationSheetRouteContent( - factory: const DraggableSheetExtentFactory(), - config: DraggableSheetExtentConfig( - debugLabel: debugLabel, - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, - gestureTamperer: gestureTamper, - physics: physics, - ), + scopeBuilder: (context, key, child) { + return DraggableSheetPositionScope( + key: key, + context: context, + isPrimary: false, + initialPosition: initialPosition, + minPosition: minPosition, + maxPosition: maxPosition, + physics: physics, + gestureTamperer: gestureTamper, + debugLabel: debugLabel, + child: child, + ); + }, child: SheetDraggable(child: child), ); } } -class ScrollableNavigationSheetRoute extends NavigationSheetRoute { +class ScrollableNavigationSheetRoute + extends NavigationSheetRoute { ScrollableNavigationSheetRoute({ super.settings, this.maintainState = true, this.transitionDuration = const Duration(milliseconds: 300), - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), this.physics, this.transitionsBuilder, required this.builder, }); - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; @override @@ -114,6 +128,13 @@ class ScrollableNavigationSheetRoute extends NavigationSheetRoute { final WidgetBuilder builder; + @override + SheetPositionScopeKey createScopeKey() { + return SheetPositionScopeKey( + debugLabel: kDebugMode ? '$debugLabel:${describeIdentity(this)}' : null, + ); + } + @override Widget buildPage( BuildContext context, @@ -122,31 +143,32 @@ class ScrollableNavigationSheetRoute extends NavigationSheetRoute { ) { return _ScrollableNavigationSheetRouteContent( debugLabel: '$ScrollableNavigationSheetRoute(${settings.name})', - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, + initialPosition: initialPosition, + minPosition: minPosition, + maxPosition: maxPosition, physics: physics, child: builder(context), ); } } -class DraggableNavigationSheetRoute extends NavigationSheetRoute { +class DraggableNavigationSheetRoute + extends NavigationSheetRoute { DraggableNavigationSheetRoute({ super.settings, this.maintainState = true, this.transitionDuration = const Duration(milliseconds: 300), - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), this.physics, this.transitionsBuilder, required this.builder, }); - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; @override @@ -160,6 +182,13 @@ class DraggableNavigationSheetRoute extends NavigationSheetRoute { final WidgetBuilder builder; + @override + SheetPositionScopeKey createScopeKey() { + return SheetPositionScopeKey( + debugLabel: kDebugMode ? '$debugLabel:${describeIdentity(this)}' : null, + ); + } + @override Widget buildPage( BuildContext context, @@ -168,9 +197,9 @@ class DraggableNavigationSheetRoute extends NavigationSheetRoute { ) { return _DraggableNavigationSheetRouteContent( debugLabel: '$DraggableNavigationSheetRoute(${settings.name})', - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, + initialPosition: initialPosition, + minPosition: minPosition, + maxPosition: maxPosition, physics: physics, child: builder(context), ); @@ -185,9 +214,9 @@ class ScrollableNavigationSheetPage extends Page { super.restorationId, this.maintainState = true, this.transitionDuration = const Duration(milliseconds: 300), - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), this.physics, this.transitionsBuilder, required this.child, @@ -198,9 +227,9 @@ class ScrollableNavigationSheetPage extends Page { final Duration transitionDuration; - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; @@ -216,7 +245,7 @@ class ScrollableNavigationSheetPage extends Page { } class _PageBasedScrollableNavigationSheetRoute - extends NavigationSheetRoute { + extends NavigationSheetRoute { _PageBasedScrollableNavigationSheetRoute({ required ScrollableNavigationSheetPage page, }) : super(settings: page); @@ -233,6 +262,13 @@ class _PageBasedScrollableNavigationSheetRoute @override RouteTransitionsBuilder? get transitionsBuilder => page.transitionsBuilder; + @override + SheetPositionScopeKey createScopeKey() { + return SheetPositionScopeKey( + debugLabel: kDebugMode ? '$debugLabel:${describeIdentity(this)}' : null, + ); + } + @override Widget buildPage( BuildContext context, @@ -241,9 +277,9 @@ class _PageBasedScrollableNavigationSheetRoute ) { return _ScrollableNavigationSheetRouteContent( debugLabel: '$ScrollableNavigationSheetPage(${page.name})', - initialExtent: page.initialExtent, - minExtent: page.minExtent, - maxExtent: page.maxExtent, + initialPosition: page.initialPosition, + minPosition: page.minPosition, + maxPosition: page.maxPosition, physics: page.physics, child: page.child, ); @@ -258,9 +294,9 @@ class DraggableNavigationSheetPage extends Page { super.restorationId, this.maintainState = true, this.transitionDuration = const Duration(milliseconds: 300), - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), this.physics, this.transitionsBuilder, required this.child, @@ -271,9 +307,9 @@ class DraggableNavigationSheetPage extends Page { final Duration transitionDuration; - final Extent initialExtent; - final Extent minExtent; - final Extent maxExtent; + final SheetAnchor initialPosition; + final SheetAnchor minPosition; + final SheetAnchor maxPosition; final SheetPhysics? physics; @@ -289,7 +325,7 @@ class DraggableNavigationSheetPage extends Page { } class _PageBasedDraggableNavigationSheetRoute - extends NavigationSheetRoute { + extends NavigationSheetRoute { _PageBasedDraggableNavigationSheetRoute({ required DraggableNavigationSheetPage page, }) : super(settings: page); @@ -306,6 +342,13 @@ class _PageBasedDraggableNavigationSheetRoute @override RouteTransitionsBuilder? get transitionsBuilder => page.transitionsBuilder; + @override + SheetPositionScopeKey createScopeKey() { + return SheetPositionScopeKey( + debugLabel: kDebugMode ? '$debugLabel:${describeIdentity(this)}' : null, + ); + } + @override Widget buildPage( BuildContext context, @@ -314,9 +357,9 @@ class _PageBasedDraggableNavigationSheetRoute ) { return _DraggableNavigationSheetRouteContent( debugLabel: '$DraggableNavigationSheetPage(${page.name})', - initialExtent: page.initialExtent, - minExtent: page.minExtent, - maxExtent: page.maxExtent, + initialPosition: page.initialPosition, + minPosition: page.minPosition, + maxPosition: page.maxPosition, physics: page.physics, child: page.child, ); diff --git a/lib/src/navigation/navigation_sheet.dart b/lib/src/navigation/navigation_sheet.dart new file mode 100644 index 00000000..a32b6d0d --- /dev/null +++ b/lib/src/navigation/navigation_sheet.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_controller.dart'; +import '../foundation/sheet_gesture_tamperer.dart'; +import '../foundation/sheet_position_scope.dart'; +import '../foundation/sheet_viewport.dart'; +import '../internal/transition_observer.dart'; +import 'navigation_sheet_position.dart'; +import 'navigation_sheet_position_scope.dart'; +import 'navigation_sheet_viewport.dart'; + +typedef NavigationSheetTransitionObserver = TransitionObserver; + +class NavigationSheet extends StatefulWidget with TransitionAwareWidgetMixin { + const NavigationSheet({ + super.key, + required this.transitionObserver, + this.controller, + required this.child, + }); + + @override + final NavigationSheetTransitionObserver transitionObserver; + + final SheetController? controller; + final Widget child; + + @override + State createState() => _NavigationSheetState(); +} + +class _NavigationSheetState extends State + with + TransitionAwareStateMixin, + TickerProviderStateMixin, + SheetContextStateMixin { + final _scopeKey = SheetPositionScopeKey( + debugLabel: kDebugMode ? 'NavigationSheet' : null, + ); + + @override + void didChangeTransitionState(Transition? transition) { + _scopeKey.maybeCurrentPosition?.handleRouteTransition(transition); + } + + @override + Widget build(BuildContext context) { + final gestureTamper = SheetGestureProxy.maybeOf(context); + final controller = + widget.controller ?? SheetControllerScope.maybeOf(context); + + return NavigationSheetPositionScope( + key: _scopeKey, + context: this, + controller: controller, + gestureTamperer: gestureTamper, + debugLabel: kDebugMode ? 'NavigationSheet' : null, + child: NavigationSheetViewport( + child: SheetContentViewport(child: widget.child), + ), + ); + } +} diff --git a/package/lib/src/navigation/navigation_sheet_activity.dart b/lib/src/navigation/navigation_sheet_activity.dart similarity index 53% rename from package/lib/src/navigation/navigation_sheet_activity.dart rename to lib/src/navigation/navigation_sheet_activity.dart index 274e9ea2..b893079d 100644 --- a/package/lib/src/navigation/navigation_sheet_activity.dart +++ b/lib/src/navigation/navigation_sheet_activity.dart @@ -4,13 +4,13 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import '../foundation/sheet_activity.dart'; -import '../foundation/sheet_extent.dart'; import '../foundation/sheet_status.dart'; -import 'navigation_sheet_extent.dart'; +import 'navigation_route.dart'; +import 'navigation_sheet_position.dart'; @internal abstract class NavigationSheetActivity - extends SheetActivity {} + extends SheetActivity {} @internal class TransitionSheetActivity extends NavigationSheetActivity { @@ -21,8 +21,8 @@ class TransitionSheetActivity extends NavigationSheetActivity { required this.animationCurve, }); - final Route currentRoute; - final Route nextRoute; + final NavigationSheetRoute currentRoute; + final NavigationSheetRoute nextRoute; final Animation animation; final Curve animationCurve; late final Animation _curvedAnimation; @@ -31,7 +31,10 @@ class TransitionSheetActivity extends NavigationSheetActivity { SheetStatus get status => SheetStatus.animating; @override - void init(NavigationSheetExtent owner) { + bool get shouldIgnorePointer => true; + + @override + void init(NavigationSheetPosition owner) { super.init(owner); _curvedAnimation = animation.drive( CurveTween(curve: animationCurve), @@ -46,64 +49,69 @@ class TransitionSheetActivity extends NavigationSheetActivity { void _onAnimationTick() { final fraction = _curvedAnimation.value; - final startPixels = owner - .getLocalExtentScopeKey(currentRoute) - .maybeCurrentExtent - ?.metrics - .maybePixels; - final endPixels = owner - .getLocalExtentScopeKey(nextRoute) - .maybeCurrentExtent - ?.metrics - .maybePixels; + final startPixels = currentRoute.scopeKey.maybeCurrentPosition?.maybePixels; + final endPixels = nextRoute.scopeKey.maybeCurrentPosition?.maybePixels; if (startPixels != null && endPixels != null) { owner.setPixels(lerpDouble(startPixels, endPixels, fraction)!); } } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldViewportInsets != null) { + absorbBottomViewportInset(owner, oldViewportInsets); + } + } } @internal class ProxySheetActivity extends NavigationSheetActivity { ProxySheetActivity({required this.route}); - final Route route; - - SheetExtentScopeKey get _scopeKey => owner.getLocalExtentScopeKey(route); + final NavigationSheetRoute route; @override SheetStatus get status => - _scopeKey.maybeCurrentExtent?.status ?? SheetStatus.stable; + route.scopeKey.maybeCurrentPosition?.status ?? SheetStatus.stable; @override - void init(NavigationSheetExtent owner) { + void init(NavigationSheetPosition owner) { super.init(owner); - _scopeKey.addOnCreatedListener(_init); + route.scopeKey.addOnCreatedListener(_onLocalPositionCreated); } - void _init() { + void _onLocalPositionCreated() { if (mounted) { - _scopeKey.currentExtent.addListener(_syncMetrics); + route.scopeKey.currentPosition.addListener(_syncMetrics); _syncMetrics(notify: false); } } @override void dispose() { - if (owner.containsLocalExtentScopeKey(route)) { - _scopeKey - ..maybeCurrentExtent?.removeListener(_syncMetrics) - ..removeOnCreatedListener(_init); - } + route.scopeKey + ..maybeCurrentPosition?.removeListener(_syncMetrics) + ..removeOnCreatedListener(_onLocalPositionCreated); super.dispose(); } void _syncMetrics({bool notify = true}) { - final metrics = _scopeKey.maybeCurrentExtent?.metrics; - if (metrics?.maybeContentSize case final contentSize?) { + assert(route.scopeKey.maybeCurrentPosition != null); + final localPosition = route.scopeKey.currentPosition; + final localMetrics = localPosition.snapshot; + owner.applyNewBoundaryConstraints( + localPosition.minPosition, + localPosition.maxPosition, + ); + if (localMetrics.maybeContentSize case final contentSize?) { owner.applyNewContentSize(contentSize); } - if (metrics?.maybePixels case final pixels?) { + if (localMetrics.maybePixels case final pixels?) { notify ? owner.setPixels(pixels) : owner.correctPixels(pixels); } } @@ -114,7 +122,7 @@ class ProxySheetActivity extends NavigationSheetActivity { Size? oldViewportSize, EdgeInsets? oldViewportInsets, ) { - // The proxied extent will handle the dimension changes, + // The proxied position will handle the dimension changes, // so we do nothing here to avoid data races. } } diff --git a/lib/src/navigation/navigation_sheet_position.dart b/lib/src/navigation/navigation_sheet_position.dart new file mode 100644 index 00000000..3d4cd28b --- /dev/null +++ b/lib/src/navigation/navigation_sheet_position.dart @@ -0,0 +1,210 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +import '../foundation/sheet_drag.dart'; +import '../foundation/sheet_position.dart'; +import '../internal/transition_observer.dart'; +import 'navigation_route.dart'; +import 'navigation_sheet_activity.dart'; + +@internal +class NavigationSheetPosition extends SheetPosition { + NavigationSheetPosition({ + required super.context, + required super.minPosition, + required super.maxPosition, + required super.physics, + super.gestureTamperer, + super.debugLabel, + }); + + Transition? _lastReportedTransition; + + @override + void takeOver(SheetPosition other) { + super.takeOver(other); + assert(_debugAssertActivityTypeConsistency()); + } + + void handleRouteTransition(Transition? transition) { + _lastReportedTransition = transition; + // TODO: Provide a way to customize animation curves. + switch (transition) { + case NoTransition(:final NavigationSheetRoute currentRoute): + beginActivity(ProxySheetActivity(route: currentRoute)); + + case ForwardTransition( + :final NavigationSheetRoute originRoute, + :final NavigationSheetRoute destinationRoute, + :final animation, + ): + beginActivity(TransitionSheetActivity( + currentRoute: originRoute, + nextRoute: destinationRoute, + animation: animation, + animationCurve: Curves.easeInOutCubic, + )); + + case BackwardTransition( + :final NavigationSheetRoute originRoute, + :final NavigationSheetRoute destinationRoute, + :final animation, + ): + beginActivity(TransitionSheetActivity( + currentRoute: originRoute, + nextRoute: destinationRoute, + animation: animation, + animationCurve: Curves.easeInOutCubic, + )); + + case UserGestureTransition( + :final NavigationSheetRoute currentRoute, + :final NavigationSheetRoute previousRoute, + :final animation, + ): + beginActivity(TransitionSheetActivity( + currentRoute: currentRoute, + nextRoute: previousRoute, + animation: animation, + animationCurve: Curves.linear, + )); + + case _: + goIdle(); + } + + assert(_debugAssertRouteType()); + assert(_debugAssertActivityTypeConsistency()); + } + + @override + void goIdle() { + switch (_lastReportedTransition) { + case NoTransition(:final NavigationSheetRoute currentRoute): + beginActivity(ProxySheetActivity(route: currentRoute)); + case _: + super.goIdle(); + } + } + + @override + Future animateTo( + SheetAnchor newPosition, { + Curve curve = Curves.easeInOut, + Duration duration = const Duration(milliseconds: 300), + }) { + if (activity case ProxySheetActivity(:final route)) { + return route.scopeKey.currentPosition + .animateTo(newPosition, curve: curve, duration: duration); + } else { + return super.animateTo(newPosition, curve: curve, duration: duration); + } + } + + @override + void didUpdateMetrics() { + // Do not dispatch a notifications if a local position is active. + if (activity is! NavigationSheetActivity) { + super.didUpdateMetrics(); + } + } + + @override + void didDragStart(SheetDragStartDetails details) { + // Do not dispatch a notifications if a local position is active. + if (activity is! NavigationSheetActivity) { + super.didDragStart(details); + } + } + + @override + void didDragEnd(SheetDragEndDetails details) { + // Do not dispatch a notifications if a local position is active. + if (activity is! NavigationSheetActivity) { + super.didDragEnd(details); + } + } + + @override + void didDragUpdateMetrics(SheetDragUpdateDetails details) { + // Do not dispatch a notifications if a local position is active. + if (activity is! NavigationSheetActivity) { + super.didDragUpdateMetrics(details); + } + } + + @override + void didOverflowBy(double overflow) { + // Do not dispatch a notifications if a local position is active. + if (activity is! NavigationSheetActivity) { + super.didOverflowBy(overflow); + } + } + + bool _debugAssertRouteType() { + assert( + () { + final lastTransition = _lastReportedTransition; + if (lastTransition is NoTransition && + lastTransition.currentRoute.isFirst && + lastTransition.currentRoute is! NavigationSheetRoute) { + throw FlutterError( + 'The first route in the navigator enclosed by a NavigationSheet ' + 'must be a NavigationSheetRoute, but actually it is a ' + '${describeIdentity(lastTransition.currentRoute)}', + ); + } + return true; + }(), + ); + return true; + } + + bool _debugAssertActivityTypeConsistency() { + assert( + () { + switch ((_lastReportedTransition, activity)) { + // Allowed patterns. + case ( + NoTransition(currentRoute: NavigationSheetRoute()), + ProxySheetActivity(), + ): + case ( + ForwardTransition( + originRoute: NavigationSheetRoute(), + destinationRoute: NavigationSheetRoute(), + ), + TransitionSheetActivity(), + ): + case ( + BackwardTransition( + originRoute: NavigationSheetRoute(), + destinationRoute: NavigationSheetRoute(), + ), + TransitionSheetActivity(), + ): + case ( + UserGestureTransition( + currentRoute: NavigationSheetRoute(), + previousRoute: NavigationSheetRoute(), + ), + TransitionSheetActivity(), + ): + case (_, final activity) when activity is! NavigationSheetActivity: + return true; + + // Other patterns are not allowed. + case (final transition, final activity): + throw FlutterError( + 'There is an inconsistency between the current transition state ' + 'and the current sheet activity type.\n' + ' Transition: $transition\n' + ' Activity: ${describeIdentity(activity)}', + ); + } + }(), + ); + return true; + } +} diff --git a/lib/src/navigation/navigation_sheet_position_scope.dart b/lib/src/navigation/navigation_sheet_position_scope.dart new file mode 100644 index 00000000..42d47017 --- /dev/null +++ b/lib/src/navigation/navigation_sheet_position_scope.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_physics.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; +import 'navigation_sheet_position.dart'; + +@internal +class NavigationSheetPositionScope extends SheetPositionScope { + const NavigationSheetPositionScope({ + super.key, + super.controller, + super.gestureTamperer, + required super.context, + this.debugLabel, + required super.child, + }) : super( + minPosition: const SheetAnchor.pixels(0), + maxPosition: const SheetAnchor.proportional(1), + // TODO: Use more appropriate physics. + physics: const ClampingSheetPhysics(), + isPrimary: true, + ); + + /// {@macro SheetPosition.debugLabel} + final String? debugLabel; + + @override + SheetPositionScopeState createState() { + return _NavigationSheetPositionScopeState(); + } +} + +class _NavigationSheetPositionScopeState extends SheetPositionScopeState< + NavigationSheetPosition, NavigationSheetPositionScope> { + @override + bool shouldRebuildPosition(NavigationSheetPosition oldPosition) { + return widget.debugLabel != oldPosition.debugLabel || + super.shouldRebuildPosition(oldPosition); + } + + @override + NavigationSheetPosition buildPosition(SheetContext context) { + return NavigationSheetPosition( + context: context, + minPosition: widget.minPosition, + maxPosition: widget.maxPosition, + physics: widget.physics, + gestureTamperer: widget.gestureTamperer, + debugLabel: widget.debugLabel, + ); + } +} diff --git a/package/lib/src/navigation/navigation_sheet_viewport.dart b/lib/src/navigation/navigation_sheet_viewport.dart similarity index 81% rename from package/lib/src/navigation/navigation_sheet_viewport.dart rename to lib/src/navigation/navigation_sheet_viewport.dart index ba79b54a..f44ba9a5 100644 --- a/package/lib/src/navigation/navigation_sheet_viewport.dart +++ b/lib/src/navigation/navigation_sheet_viewport.dart @@ -2,7 +2,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import '../foundation/sheet_extent.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; import '../foundation/sheet_viewport.dart'; @internal @@ -15,7 +16,7 @@ class NavigationSheetViewport extends SheetViewport { @override RenderObject createRenderObject(BuildContext context) { return _RenderNavigationSheetViewport( - SheetExtentScope.of(context), + SheetPositionScope.of(context), MediaQuery.viewInsetsOf(context), ); } @@ -41,7 +42,7 @@ class NavigationSheetViewport extends SheetViewport { } class _RenderNavigationSheetViewport extends RenderSheetViewport { - _RenderNavigationSheetViewport(super.extent, super.insets); + _RenderNavigationSheetViewport(super.position, super.insets); final _children = <_RenderNavigationSheetRouteViewport>[]; @@ -83,7 +84,7 @@ class NavigationSheetRouteViewport extends SingleChildRenderObjectWidget { RenderObject createRenderObject(BuildContext context) { return _RenderNavigationSheetRouteViewport( globalViewport: NavigationSheetViewport._of(context), - localExtent: SheetExtentScope.of(context), + localPosition: SheetPositionScope.of(context), ); } @@ -91,20 +92,21 @@ class NavigationSheetRouteViewport extends SingleChildRenderObjectWidget { void updateRenderObject(BuildContext context, RenderObject renderObject) { (renderObject as _RenderNavigationSheetRouteViewport) ..globalViewport = NavigationSheetViewport._of(context) - ..localExtent = SheetExtentScope.of(context); + ..localPosition = SheetPositionScope.of(context); } } class _RenderNavigationSheetRouteViewport extends RenderProxyBox { _RenderNavigationSheetRouteViewport({ required _RenderNavigationSheetViewport globalViewport, - required SheetExtent localExtent, + required SheetPosition localPosition, }) : _globalViewport = globalViewport, - _localExtent = localExtent { + _localPosition = localPosition { _globalViewport.addChild(this); } _RenderNavigationSheetViewport _globalViewport; + // ignore: avoid_setters_without_getters set globalViewport(_RenderNavigationSheetViewport value) { if (_globalViewport != value) { @@ -113,11 +115,12 @@ class _RenderNavigationSheetRouteViewport extends RenderProxyBox { } } - SheetExtent _localExtent; + SheetPosition _localPosition; + // ignore: avoid_setters_without_getters - set localExtent(SheetExtent value) { - if (_localExtent != value) { - _localExtent = value; + set localPosition(SheetPosition value) { + if (_localPosition != value) { + _localPosition = value; markNeedsLayout(); } } @@ -130,15 +133,15 @@ class _RenderNavigationSheetRouteViewport extends RenderProxyBox { @override void performLayout() { - _localExtent.markAsDimensionsWillChange(); - // Notify the SheetExtent about the viewport size changes + _localPosition.markAsDimensionsWillChange(); + // Notify the SheetPosition about the viewport size changes // before performing the layout so that the descendant widgets // can use the viewport size during the layout phase. - _localExtent.applyNewViewportDimensions( + _localPosition.applyNewViewportDimensions( Size.copy(_globalViewport.lastMeasuredSize!), _globalViewport.insets, ); super.performLayout(); - _localExtent.markAsDimensionsChanged(); + _localPosition.markAsDimensionsChanged(); } } diff --git a/package/lib/src/scrollable/scrollable.dart b/lib/src/scrollable/scrollable.dart similarity index 100% rename from package/lib/src/scrollable/scrollable.dart rename to lib/src/scrollable/scrollable.dart diff --git a/lib/src/scrollable/scrollable_sheet.dart b/lib/src/scrollable/scrollable_sheet.dart new file mode 100644 index 00000000..503d6e94 --- /dev/null +++ b/lib/src/scrollable/scrollable_sheet.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_controller.dart'; +import '../foundation/sheet_gesture_tamperer.dart'; +import '../foundation/sheet_physics.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_theme.dart'; +import '../foundation/sheet_viewport.dart'; +import 'scrollable_sheet_position_scope.dart'; +import 'sheet_scrollable.dart'; + +class ScrollableSheet extends StatefulWidget { + const ScrollableSheet({ + super.key, + this.initialPosition = const SheetAnchor.proportional(1), + this.minPosition = const SheetAnchor.proportional(1), + this.maxPosition = const SheetAnchor.proportional(1), + this.physics, + this.controller, + required this.child, + }); + + /// {@macro ScrollableSheetPosition.initialPosition} + final SheetAnchor initialPosition; + + /// {@macro SheetPosition.minPosition} + final SheetAnchor minPosition; + + /// {@macro SheetPosition.maxPosition} + final SheetAnchor maxPosition; + + /// {@macro SheetPosition.physics} + final SheetPhysics? physics; + + /// An object that can be used to control and observe the sheet height. + final SheetController? controller; + + /// The content of the sheet. + final Widget child; + + @override + State createState() => _ScrollableSheetState(); +} + +class _ScrollableSheetState extends State + with TickerProviderStateMixin, SheetContextStateMixin { + @override + Widget build(BuildContext context) { + final theme = SheetTheme.maybeOf(context); + final physics = widget.physics ?? theme?.physics ?? kDefaultSheetPhysics; + final gestureTamper = SheetGestureProxy.maybeOf(context); + final controller = + widget.controller ?? SheetControllerScope.maybeOf(context); + + return ScrollableSheetPositionScope( + context: this, + controller: controller, + initialPosition: widget.initialPosition, + minPosition: widget.minPosition, + maxPosition: widget.maxPosition, + physics: physics, + gestureTamperer: gestureTamper, + debugLabel: kDebugMode ? 'ScrollableSheet' : null, + child: SheetViewport( + child: SheetContentViewport( + child: ScrollableSheetContent(child: widget.child), + ), + ), + ); + } +} + +@internal +class ScrollableSheetContent extends StatelessWidget { + const ScrollableSheetContent({ + super.key, + this.debugLabel, + this.keepScrollOffset = true, + this.initialScrollOffset = 0, + required this.child, + }); + + final String? debugLabel; + final bool keepScrollOffset; + final double initialScrollOffset; + final Widget child; + + @override + Widget build(BuildContext context) { + return SheetScrollable( + debugLabel: debugLabel, + keepScrollOffset: keepScrollOffset, + initialScrollOffset: initialScrollOffset, + builder: (context, controller) { + return PrimaryScrollController( + controller: controller, + child: child, + ); + }, + ); + } +} diff --git a/package/lib/src/scrollable/scrollable_sheet_activity.dart b/lib/src/scrollable/scrollable_sheet_activity.dart similarity index 54% rename from package/lib/src/scrollable/scrollable_sheet_activity.dart rename to lib/src/scrollable/scrollable_sheet_activity.dart index 69289db6..1bc15f9c 100644 --- a/package/lib/src/scrollable/scrollable_sheet_activity.dart +++ b/lib/src/scrollable/scrollable_sheet_activity.dart @@ -1,24 +1,39 @@ import 'dart:math'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import '../foundation/sheet_activity.dart'; import '../foundation/sheet_drag.dart'; -import '../internal/double_utils.dart'; +import '../foundation/sheet_status.dart'; +import '../internal/float_comp.dart'; import 'scrollable_sheet.dart'; -import 'scrollable_sheet_extent.dart'; +import 'scrollable_sheet_position.dart'; import 'sheet_content_scroll_activity.dart'; import 'sheet_content_scroll_position.dart'; +/// A [SheetActivity] that is associated with a [SheetContentScrollPosition]. +/// +/// This activity is responsible for both scrolling a scrollable content +/// in the sheet and dragging the sheet itself. +/// +/// [shouldIgnorePointer] and [SheetContentScrollPosition.shouldIgnorePointer] +/// of the associated scroll position may be synchronized, but not always. +/// For example, [BallisticScrollDrivenSheetActivity]'s [shouldIgnorePointer] +/// is always `false` while the associated scroll position sets it to `true` +/// in most cases to ensure that the pointer events, which potentially +/// interrupt the ballistic scroll animation, are not stolen by clickable +/// items in the scroll view. @internal abstract class ScrollableSheetActivity - extends SheetActivity { + extends SheetActivity { ScrollableSheetActivity(SheetContentScrollPosition scrollPosition) : _scrollPosition = scrollPosition; SheetContentScrollPosition? _scrollPosition; + SheetContentScrollPosition get scrollPosition { assert(debugAssertNotDisposed()); return _scrollPosition!; @@ -36,82 +51,83 @@ abstract class ScrollableSheetActivity } double _applyPhysicsToOffset(double offset) { - return owner.config.physics.applyPhysicsToOffset(offset, owner.metrics); + return owner.physics.applyPhysicsToOffset(offset, owner.snapshot); } double _applyScrollOffset(double offset) { - if (offset.isApprox(0)) return 0; - - final position = scrollPosition; - final maxPixels = owner.metrics.maxPixels; - final oldPixels = owner.metrics.pixels; - final oldScrollPixels = position.pixels; - final minScrollPixels = position.minScrollExtent; - final maxScrollPixels = position.maxScrollExtent; + final cmp = FloatComp.distance(owner.context.devicePixelRatio); + if (cmp.isApprox(offset, 0)) return 0; + + final maxPixels = owner.maxPixels; + final oldPixels = owner.pixels; + final oldScrollPixels = scrollPosition.pixels; + final minScrollPixels = scrollPosition.minScrollExtent; + final maxScrollPixels = scrollPosition.maxScrollExtent; var newPixels = oldPixels; var delta = offset; if (offset > 0) { // If the sheet is not at top, drag it up as much as possible // until it reaches at 'maxPixels'. - if (newPixels.isLessThanOrApprox(maxPixels)) { + if (cmp.isLessThanOrApprox(newPixels, maxPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.isLessThanOrApprox(delta)); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta, delta)); newPixels = min(newPixels + physicsAppliedDelta, maxPixels); delta -= newPixels - oldPixels; } // If the sheet is at the top, scroll the content up as much as possible. - if (newPixels.isGreaterThanOrApprox(maxPixels) && - position.extentAfter > 0) { - position.correctPixels(min(position.pixels + delta, maxScrollPixels)); - delta -= position.pixels - oldScrollPixels; + if (cmp.isGreaterThanOrApprox(newPixels, maxPixels) && + scrollPosition.extentAfter > 0) { + scrollPosition + .correctPixels(min(scrollPosition.pixels + delta, maxScrollPixels)); + delta -= scrollPosition.pixels - oldScrollPixels; } // If the content cannot be scrolled up anymore, drag the sheet up - // to make a stretching effect (if needed). - if (position.pixels.isApprox(maxScrollPixels)) { + // to make a bouncing effect (if needed). + if (cmp.isApprox(scrollPosition.pixels, maxScrollPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.isLessThanOrApprox(delta)); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta, delta)); newPixels += physicsAppliedDelta; delta -= physicsAppliedDelta; } } else if (offset < 0) { // If the sheet is beyond 'maxPixels', drag it down as much // as possible until it reaches at 'maxPixels'. - if (newPixels.isGreaterThanOrApprox(maxPixels)) { + if (cmp.isGreaterThanOrApprox(newPixels, maxPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.abs().isLessThanOrApprox(delta.abs())); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta.abs(), delta.abs())); newPixels = max(newPixels + physicsAppliedDelta, maxPixels); delta -= newPixels - oldPixels; } // If the sheet is not beyond 'maxPixels', scroll the content down // as much as possible. - if (newPixels.isLessThanOrApprox(maxPixels) && - position.extentBefore > 0) { - position.correctPixels(max(position.pixels + delta, minScrollPixels)); - delta -= position.pixels - oldScrollPixels; + if (cmp.isLessThanOrApprox(newPixels, maxPixels) && + scrollPosition.extentBefore > 0) { + scrollPosition + .correctPixels(max(scrollPosition.pixels + delta, minScrollPixels)); + delta -= scrollPosition.pixels - oldScrollPixels; } // If the content cannot be scrolled down anymore, drag the sheet down // to make a shrinking effect (if needed). - if (position.pixels.isApprox(minScrollPixels)) { + if (cmp.isApprox(scrollPosition.pixels, minScrollPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.abs().isLessThanOrApprox(delta.abs())); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta.abs(), delta.abs())); newPixels += physicsAppliedDelta; delta -= physicsAppliedDelta; } } - if (position.pixels != oldScrollPixels) { - position + if (scrollPosition.pixels != oldScrollPixels) { + scrollPosition ..notifyListeners() - ..didUpdateScrollPositionBy(position.pixels - oldScrollPixels); + ..didUpdateScrollPositionBy(scrollPosition.pixels - oldScrollPixels); } owner.setPixels(newPixels); - final overflow = owner.config.physics.computeOverflow(delta, owner.metrics); + final overflow = owner.physics.computeOverflow(delta, owner.snapshot); if (overflow.abs() > 0) { - position.didOverscrollBy(overflow); - owner.dispatchOverflowNotification(overflow); + scrollPosition.didOverscrollBy(overflow); return overflow; } @@ -156,7 +172,7 @@ class DragScrollDrivenSheetActivity extends ScrollableSheetActivity @override Offset computeMinPotentialDeltaConsumption(Offset delta) { - final metrics = owner.metrics; + final metrics = owner.snapshot; switch (delta.dy) { case < 0: @@ -177,19 +193,38 @@ class DragScrollDrivenSheetActivity extends ScrollableSheetActivity } @override - void applyUserDragUpdate(Offset delta) { - scrollPosition.userScrollDirection = - delta.dy > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse; - _applyScrollOffset(-1 * delta.dy); + void onDragUpdate(SheetDragUpdateDetails details) { + scrollPosition.userScrollDirection = details.deltaY > 0.0 + ? ScrollDirection.forward + : ScrollDirection.reverse; + final oldPixels = owner.pixels; + final overflow = _applyScrollOffset(-1 * details.deltaY); + if (owner.pixels != oldPixels) { + owner.didDragUpdateMetrics(details); + } + if (overflow > 0) { + owner.didOverflowBy(overflow); + } } @override - void applyUserDragEnd(Velocity velocity) { - owner.goBallisticWithScrollPosition( - velocity: -1 * velocity.pixelsPerSecond.dy, - shouldIgnorePointer: false, - scrollPosition: scrollPosition, - ); + void onDragEnd(SheetDragEndDetails details) { + owner + ..didDragEnd(details) + ..goBallisticWithScrollPosition( + velocity: -1 * details.velocityY, + scrollPosition: scrollPosition, + ); + } + + @override + void onDragCancel(SheetDragCancelDetails details) { + owner + ..didDragCancel() + ..goBallisticWithScrollPosition( + velocity: 0, + scrollPosition: scrollPosition, + ); } } @@ -206,11 +241,9 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity super.scrollPosition, { required this.simulation, required double initialPixels, - required this.shouldIgnorePointer, }) : _oldPixels = initialPixels; final Simulation simulation; - final bool shouldIgnorePointer; double _oldPixels; @@ -226,20 +259,27 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity @override void onAnimationTick() { + final cmp = FloatComp.distance(owner.context.devicePixelRatio); final delta = controller.value - _oldPixels; _oldPixels = controller.value; - final overscroll = _applyScrollOffset(delta); - - if (!overscroll.isApprox(0)) { - owner.goIdleWithScrollPosition(); + final overflow = _applyScrollOffset(delta); + if (owner.pixels != _oldPixels) { + owner.didUpdateMetrics(); + } + if (cmp.isNotApprox(overflow, 0)) { + owner + ..didOverflowBy(overflow) + ..goIdleWithScrollPosition(); return; } + final scrollExtentBefore = scrollPosition.extentBefore; + final scrollExtentAfter = scrollPosition.extentAfter; final shouldInterruptBallisticScroll = - ((scrollPosition.extentBefore.isApprox(0) && velocity < 0) || - (scrollPosition.extentAfter.isApprox(0) && velocity > 0)) && - owner.config.physics - .shouldInterruptBallisticScroll(velocity, owner.metrics); + ((cmp.isApprox(scrollExtentBefore, 0) && velocity < 0) || + (cmp.isApprox(scrollExtentAfter, 0) && velocity > 0)) && + owner.physics + .shouldInterruptBallisticScroll(velocity, owner.snapshot); if (shouldInterruptBallisticScroll) { _end(); @@ -256,8 +296,43 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity void _end() { owner.goBallisticWithScrollPosition( velocity: 0, - shouldIgnorePointer: shouldIgnorePointer, scrollPosition: scrollPosition, ); } } + +/// A [SheetActivity] that does nothing but can be released to resume +/// normal idle behavior. +/// +/// This is used while the user is touching the scrollable content but before +/// the touch has become a [Drag]. The [scrollPosition], which is associated +/// with the scrollable content must have a [SheetContentHoldScrollActivity] +/// as its activity throughout the lifetime of this activity. +class HoldScrollDrivenSheetActivity extends ScrollableSheetActivity + implements ScrollHoldController { + HoldScrollDrivenSheetActivity( + super.scrollPosition, { + required this.heldPreviousVelocity, + required this.onHoldCanceled, + }); + + final double heldPreviousVelocity; + final VoidCallback? onHoldCanceled; + + @override + SheetStatus get status => SheetStatus.dragging; + + @override + void cancel() { + owner.goBallisticWithScrollPosition( + velocity: 0, + scrollPosition: scrollPosition, + ); + } + + @override + void dispose() { + onHoldCanceled?.call(); + super.dispose(); + } +} diff --git a/package/lib/src/scrollable/scrollable_sheet_physics.dart b/lib/src/scrollable/scrollable_sheet_physics.dart similarity index 77% rename from package/lib/src/scrollable/scrollable_sheet_physics.dart rename to lib/src/scrollable/scrollable_sheet_physics.dart index 043772d7..0c07f994 100644 --- a/package/lib/src/scrollable/scrollable_sheet_physics.dart +++ b/lib/src/scrollable/scrollable_sheet_physics.dart @@ -1,8 +1,8 @@ import 'package:flutter/physics.dart'; import 'package:meta/meta.dart'; -import '../foundation/sheet_extent.dart'; import '../foundation/sheet_physics.dart'; +import '../foundation/sheet_position.dart'; @internal class ScrollableSheetPhysics extends SheetPhysics with SheetPhysicsMixin { @@ -12,6 +12,13 @@ class ScrollableSheetPhysics extends SheetPhysics with SheetPhysicsMixin { this.maxScrollSpeedToInterrupt = double.infinity, }) : assert(maxScrollSpeedToInterrupt >= 0); + factory ScrollableSheetPhysics.wrap(SheetPhysics physics) { + return switch (physics) { + final ScrollableSheetPhysics scrollablePhysics => scrollablePhysics, + final otherPhysics => ScrollableSheetPhysics(parent: otherPhysics), + }; + } + // TODO: Expose this from the ScrollableSheet's constructor final double maxScrollSpeedToInterrupt; diff --git a/package/lib/src/scrollable/scrollable_sheet_extent.dart b/lib/src/scrollable/scrollable_sheet_position.dart similarity index 59% rename from package/lib/src/scrollable/scrollable_sheet_extent.dart rename to lib/src/scrollable/scrollable_sheet_position.dart index ffc1b941..71cb6ee3 100644 --- a/package/lib/src/scrollable/scrollable_sheet_extent.dart +++ b/lib/src/scrollable/scrollable_sheet_position.dart @@ -5,90 +5,38 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import '../foundation/sheet_drag.dart'; -import '../foundation/sheet_extent.dart'; -import '../foundation/sheet_gesture_tamperer.dart'; import '../foundation/sheet_physics.dart'; -import '../internal/double_utils.dart'; +import '../foundation/sheet_position.dart'; +import '../internal/float_comp.dart'; import 'scrollable_sheet_activity.dart'; import 'scrollable_sheet_physics.dart'; import 'sheet_content_scroll_activity.dart'; import 'sheet_content_scroll_position.dart'; @internal -class ScrollableSheetExtentFactory extends SheetExtentFactory< - ScrollableSheetExtentConfig, ScrollableSheetExtent> { - const ScrollableSheetExtentFactory(); - - @override - ScrollableSheetExtent createSheetExtent({ - required SheetContext context, - required ScrollableSheetExtentConfig config, - }) { - return ScrollableSheetExtent(context: context, config: config); - } -} - -@internal -class ScrollableSheetExtentConfig extends SheetExtentConfig { - const ScrollableSheetExtentConfig({ - required this.initialExtent, - required super.minExtent, - required super.maxExtent, - required super.gestureTamperer, - required ScrollableSheetPhysics physics, +class ScrollableSheetPosition extends SheetPosition + implements SheetContentScrollPositionOwner { + ScrollableSheetPosition({ + required super.context, + required this.initialPosition, + required super.minPosition, + required super.maxPosition, + required SheetPhysics physics, + super.gestureTamperer, super.debugLabel, - }) : super(physics: physics); - - factory ScrollableSheetExtentConfig.withFallbacks({ - required Extent initialExtent, - required Extent minExtent, - required Extent maxExtent, - required SheetGestureTamperer? gestureTamperer, - SheetPhysics? physics, - String? debugLabel, - }) { - return ScrollableSheetExtentConfig( - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, - gestureTamperer: gestureTamperer, - debugLabel: debugLabel, - physics: switch (physics) { - null => const ScrollableSheetPhysics(parent: kDefaultSheetPhysics), - final ScrollableSheetPhysics scrollablePhysics => scrollablePhysics, - final otherPhysics => ScrollableSheetPhysics(parent: otherPhysics), - }, - ); - } + }) : super(physics: ScrollableSheetPhysics.wrap(physics)); - final Extent initialExtent; + /// {@template ScrollableSheetPosition.initialPosition} + /// The initial position of the sheet. + /// {@endtemplate} + final SheetAnchor initialPosition; @override ScrollableSheetPhysics get physics => super.physics as ScrollableSheetPhysics; - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is ScrollableSheetExtentConfig && - other.initialExtent == initialExtent && - super == other; - } - - @override - int get hashCode => Object.hash( - initialExtent, - super.hashCode, - ); -} - -@internal -class ScrollableSheetExtent extends SheetExtent - implements SheetContentScrollPositionOwner { - ScrollableSheetExtent({required super.context, required super.config}); - final _scrollPositions = HashSet(); - /// A [ScrollPosition] that is currently driving the sheet extent. + /// A [ScrollPosition] that is currently driving the sheet position. SheetContentScrollPosition? get _primaryScrollPosition => switch (activity) { final ScrollableSheetActivity activity => activity.scrollPosition, _ => null, @@ -128,18 +76,23 @@ class ScrollableSheetExtent extends SheetExtent } } + @override + void updatePhysics(SheetPhysics physics) { + super.updatePhysics(ScrollableSheetPhysics.wrap(physics)); + } + @override void applyNewContentSize(Size contentSize) { super.applyNewContentSize(contentSize); - if (metrics.maybePixels == null) { - setPixels(config.initialExtent.resolve(metrics.contentSize)); + if (maybePixels == null) { + setPixels(initialPosition.resolve(contentSize)); } } @override - void takeOver(SheetExtent other) { + void takeOver(SheetPosition other) { super.takeOver(other); - if (other is ScrollableSheetExtent) { + if (other is ScrollableSheetPosition) { assert(_scrollPositions.isEmpty); _scrollPositions.addAll(other._scrollPositions); other._scrollPositions.clear(); @@ -163,6 +116,24 @@ class ScrollableSheetExtent extends SheetExtent goIdle(); } + @override + ScrollHoldController holdWithScrollPosition({ + required double heldPreviousVelocity, + required VoidCallback holdCancelCallback, + required SheetContentScrollPosition scrollPosition, + }) { + final holdActivity = HoldScrollDrivenSheetActivity( + scrollPosition, + onHoldCanceled: holdCancelCallback, + heldPreviousVelocity: heldPreviousVelocity, + ); + scrollPosition.beginActivity( + SheetContentHoldScrollActivity(delegate: scrollPosition), + ); + beginActivity(holdActivity); + return holdActivity; + } + @override Drag dragWithScrollPosition({ required DragStartDetails details, @@ -180,40 +151,46 @@ class ScrollableSheetExtent extends SheetExtent globalPositionY: details.globalPosition.dy, kind: details.kind, ); - if (config.gestureTamperer case final tamperer?) { - startDetails = tamperer.tamperWithDragStart(startDetails); + if (gestureTamperer case final tamperer?) { + startDetails = tamperer.onDragStart(startDetails); } - final drag = currentDrag = SheetDragController( + final heldPreviousVelocity = switch (activity) { + final HoldScrollDrivenSheetActivity holdActivity => + holdActivity.heldPreviousVelocity, + _ => 0.0, + }; + final drag = SheetDragController( target: dragActivity, - gestureTamperer: config.gestureTamperer, + gestureTamperer: gestureTamperer, details: startDetails, onDragCanceled: dragCancelCallback, - carriedVelocity: scrollPosition.physics - .carriedMomentum(scrollPosition.heldPreviousVelocity), + carriedVelocity: + scrollPosition.physics.carriedMomentum(heldPreviousVelocity), motionStartDistanceThreshold: scrollPosition.physics.dragStartDistanceMotionThreshold, ); scrollPosition.beginActivity( SheetContentDragScrollActivity( delegate: scrollPosition, - getLastDragDetails: () => currentDrag?.lastRawDetails, - getPointerDeviceKind: () => currentDrag?.pointerDeviceKind, + getLastDragDetails: () => drag.lastRawDetails, + getPointerDeviceKind: () => drag.pointerDeviceKind, ), ); beginActivity(dragActivity); + currentDrag = drag; + didDragStart(startDetails); return drag; } @override void goBallisticWithScrollPosition({ required double velocity, - required bool shouldIgnorePointer, required SheetContentScrollPosition scrollPosition, }) { - assert(metrics.hasDimensions); - if (scrollPosition.pixels.isApprox(scrollPosition.minScrollExtent)) { - final simulation = - config.physics.createBallisticSimulation(velocity, metrics); + assert(hasDimensions); + if (FloatComp.distance(context.devicePixelRatio) + .isApprox(scrollPosition.pixels, scrollPosition.minScrollExtent)) { + final simulation = physics.createBallisticSimulation(velocity, snapshot); if (simulation != null) { scrollPosition.goIdle(calledByOwner: true); goBallisticWith(simulation); @@ -222,17 +199,30 @@ class ScrollableSheetExtent extends SheetExtent } final scrolledDistance = scrollPosition.pixels; - final draggedDistance = metrics.pixels - metrics.minPixels; - final draggableDistance = metrics.maxPixels - metrics.minPixels; + final draggedDistance = pixels - minPixels; + final draggableDistance = maxPixels - minPixels; final scrollableDistance = scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent; final scrollPixelsForScrollPhysics = scrolledDistance + draggedDistance; + final maxScrollExtentForScrollPhysics = + draggableDistance + scrollableDistance; final scrollMetricsForScrollPhysics = scrollPosition.copyWith( minScrollExtent: 0, - // How many pixels the user can scroll/drag - maxScrollExtent: draggableDistance + scrollableDistance, - // How many pixels the user scrolled/dragged - pixels: scrollPixelsForScrollPhysics, + // How many pixels the user can scroll and drag. + maxScrollExtent: maxScrollExtentForScrollPhysics, + // How many pixels the user has scrolled and dragged. + pixels: FloatComp.distance(context.devicePixelRatio).roundToEdgeIfApprox( + // Round the scrollPixelsForScrollPhysics to 0.0 or the maxScrollExtent + // if necessary to prevents issues with floating-point precision errors. + // For example, issue #207 and #212 were caused by infinite recursion of + // SheetContentScrollPositionOwner.goBallisticWithScrollPosition calls, + // triggered by ScrollMetrics.outOfRange always being true in + // ScrollPhysics.createBallisticSimulation due to such a floating-point + // precision error. + scrollPixelsForScrollPhysics, + 0, + maxScrollExtentForScrollPhysics, + ), ); final scrollSimulation = scrollPosition.physics @@ -243,13 +233,12 @@ class ScrollableSheetExtent extends SheetExtent scrollPosition, simulation: scrollSimulation, initialPixels: scrollPixelsForScrollPhysics, - shouldIgnorePointer: shouldIgnorePointer, ), ); scrollPosition.beginActivity( SheetContentBallisticScrollActivity( delegate: scrollPosition, - shouldIgnorePointer: shouldIgnorePointer, + shouldIgnorePointer: scrollPosition.shouldIgnorePointer, getVelocity: () => activity.velocity, ), ); diff --git a/lib/src/scrollable/scrollable_sheet_position_scope.dart b/lib/src/scrollable/scrollable_sheet_position_scope.dart new file mode 100644 index 00000000..195a13f1 --- /dev/null +++ b/lib/src/scrollable/scrollable_sheet_position_scope.dart @@ -0,0 +1,57 @@ +import 'package:meta/meta.dart'; + +import '../foundation/sheet_context.dart'; +import '../foundation/sheet_position.dart'; +import '../foundation/sheet_position_scope.dart'; +import 'scrollable_sheet_position.dart'; + +@internal +class ScrollableSheetPositionScope extends SheetPositionScope { + const ScrollableSheetPositionScope({ + super.key, + super.controller, + super.isPrimary, + required super.context, + required this.initialPosition, + required super.minPosition, + required super.maxPosition, + required super.physics, + super.gestureTamperer, + this.debugLabel, + required super.child, + }); + + /// {@macro ScrollableSheetPosition.initialPosition} + final SheetAnchor initialPosition; + + /// {@macro SheetPosition.debugLabel} + final String? debugLabel; + + @override + SheetPositionScopeState createState() { + return _ScrollableSheetPositionScopeState(); + } +} + +class _ScrollableSheetPositionScopeState extends SheetPositionScopeState< + ScrollableSheetPosition, ScrollableSheetPositionScope> { + @override + bool shouldRebuildPosition(ScrollableSheetPosition oldPosition) { + return widget.initialPosition != oldPosition.initialPosition || + widget.debugLabel != oldPosition.debugLabel || + super.shouldRebuildPosition(oldPosition); + } + + @override + ScrollableSheetPosition buildPosition(SheetContext context) { + return ScrollableSheetPosition( + context: context, + initialPosition: widget.initialPosition, + minPosition: widget.minPosition, + maxPosition: widget.maxPosition, + physics: widget.physics, + gestureTamperer: widget.gestureTamperer, + debugLabel: widget.debugLabel, + ); + } +} diff --git a/package/lib/src/scrollable/sheet_content_scroll_activity.dart b/lib/src/scrollable/sheet_content_scroll_activity.dart similarity index 87% rename from package/lib/src/scrollable/sheet_content_scroll_activity.dart rename to lib/src/scrollable/sheet_content_scroll_activity.dart index 8ae2d5ea..0e58fa4e 100644 --- a/package/lib/src/scrollable/sheet_content_scroll_activity.dart +++ b/lib/src/scrollable/sheet_content_scroll_activity.dart @@ -171,3 +171,24 @@ class SheetContentBallisticScrollActivity extends ScrollActivity { @override double get velocity => getVelocity(); } + +/// A [ScrollActivity] for the [SheetContentScrollPosition] that is associated +/// with a [HoldScrollDrivenSheetActivity]. +/// +/// This activity is like a placeholder, meaning it doesn't actually modify the +/// scroll position and the actual scrolling is done by the associated +/// [HoldScrollDrivenSheetActivity]. +class SheetContentHoldScrollActivity extends ScrollActivity { + SheetContentHoldScrollActivity({ + required ScrollActivityDelegate delegate, + }) : super(delegate); + + @override + bool get shouldIgnorePointer => false; + + @override + bool get isScrolling => false; + + @override + double get velocity => 0.0; +} diff --git a/package/lib/src/scrollable/sheet_content_scroll_position.dart b/lib/src/scrollable/sheet_content_scroll_position.dart similarity index 86% rename from package/lib/src/scrollable/sheet_content_scroll_position.dart rename to lib/src/scrollable/sheet_content_scroll_position.dart index 0bd440e9..49d4f7ab 100644 --- a/package/lib/src/scrollable/sheet_content_scroll_position.dart +++ b/lib/src/scrollable/sheet_content_scroll_position.dart @@ -10,7 +10,7 @@ import 'scrollable_sheet.dart'; /// An owner of [SheetContentScrollPosition]s. /// /// The associated scroll positions delegate their behavior of -/// `goIdle`, `drag`, and `goBallistic` to this owner. +/// `goIdle`, `hold`, `drag`, and `goBallistic` to this owner. @internal abstract class SheetContentScrollPositionOwner { bool get hasPrimaryScrollPosition; @@ -24,6 +24,12 @@ abstract class SheetContentScrollPositionOwner { void goIdleWithScrollPosition(); + ScrollHoldController holdWithScrollPosition({ + required double heldPreviousVelocity, + required VoidCallback holdCancelCallback, + required SheetContentScrollPosition scrollPosition, + }); + Drag dragWithScrollPosition({ required DragStartDetails details, required VoidCallback dragCancelCallback, @@ -32,7 +38,6 @@ abstract class SheetContentScrollPositionOwner { void goBallisticWithScrollPosition({ required double velocity, - required bool shouldIgnorePointer, required SheetContentScrollPosition scrollPosition, }); } @@ -60,14 +65,9 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { /// this object to the controller, and it is unset when detaching. ValueGetter? _getOwner; - /// Velocity from a previous activity temporarily held by [hold] - /// to potentially transfer to a next activity. - /// - /// This mirrors the value of `_heldPreviousVelocity` in - /// [ScrollPositionWithSingleContext] and is exposed here for - /// being used from outside of this object. - double _heldPreviousVelocity = 0.0; - double get heldPreviousVelocity => _heldPreviousVelocity; + /// Whether the scroll view should prevent its contents from receiving + /// pointer events. + bool get shouldIgnorePointer => activity!.shouldIgnorePointer; /// Sets the user scroll direction. /// @@ -88,13 +88,6 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { super.absorb(other); } - @override - void beginActivity(ScrollActivity? newActivity) { - _heldPreviousVelocity = - newActivity is HoldScrollActivity ? activity!.velocity : 0.0; - super.beginActivity(newActivity); - } - @override void goIdle({bool calledByOwner = false}) { final owner = _getOwner?.call(); @@ -105,6 +98,18 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { } } + @override + ScrollHoldController hold(VoidCallback holdCancelCallback) { + return switch (_getOwner?.call()) { + null => super.hold(holdCancelCallback), + final owner => owner.holdWithScrollPosition( + scrollPosition: this, + holdCancelCallback: holdCancelCallback, + heldPreviousVelocity: activity!.velocity, + ), + }; + } + @override Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { return switch (_getOwner?.call()) { @@ -123,7 +128,6 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { if (owner != null && owner.hasPrimaryScrollPosition && !calledByOwner) { owner.goBallisticWithScrollPosition( velocity: velocity, - shouldIgnorePointer: activity?.shouldIgnorePointer ?? true, scrollPosition: this, ); return; diff --git a/package/lib/src/scrollable/sheet_scrollable.dart b/lib/src/scrollable/sheet_scrollable.dart similarity index 87% rename from package/lib/src/scrollable/sheet_scrollable.dart rename to lib/src/scrollable/sheet_scrollable.dart index 38f3f9cd..98e46128 100644 --- a/package/lib/src/scrollable/sheet_scrollable.dart +++ b/lib/src/scrollable/sheet_scrollable.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import '../foundation/sheet_extent.dart'; -import 'scrollable_sheet_extent.dart'; +import '../foundation/sheet_position_scope.dart'; +import 'scrollable_sheet_position.dart'; import 'sheet_content_scroll_position.dart'; class SheetScrollable extends StatefulWidget { @@ -25,7 +25,7 @@ class SheetScrollable extends StatefulWidget { class _SheetScrollableState extends State { late ScrollController _scrollController; - ScrollableSheetExtent? _extent; + ScrollableSheetPosition? _position; @override void initState() { @@ -36,7 +36,7 @@ class _SheetScrollableState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - _extent = SheetExtentScope.maybeOf(context); + _position = SheetPositionScope.maybeOf(context); } @override @@ -53,7 +53,7 @@ class _SheetScrollableState extends State { @factory SheetContentScrollController createController() { return SheetContentScrollController( - getOwner: () => _extent, + getOwner: () => _position, debugLabel: widget.debugLabel, initialScrollOffset: widget.initialScrollOffset, keepScrollOffset: widget.keepScrollOffset, @@ -62,7 +62,7 @@ class _SheetScrollableState extends State { @override void dispose() { - _extent = null; + _position = null; _scrollController.dispose(); super.dispose(); } diff --git a/migrations/migration-guide-0.10.x.md b/migrations/migration-guide-0.10.x.md new file mode 100644 index 00000000..c7ae846f --- /dev/null +++ b/migrations/migration-guide-0.10.x.md @@ -0,0 +1,36 @@ +# Migration Guide: 0.10.x from 0.9.x + +The changes in v0.10.0 are summarized as follows. Breaking changes are marked with :boom:. + +## Changes in `SheetMetrics` :boom: + +- The `double? minPixels` and `double? maxPixels` parameters in the `copyWith` method have been + replaced with `Extent? minPosition` and `Extent? maxPosition`, respectively. The `minPixels` + and `maxPixels` getters are still available in this version. + +- `SheetMetrics` is now a mixin and can no longer be instantiated directly. Use + the `SheetMetricsSnapshot` class for this purpose. + +## Change in `SnappingSheetBehavior` and its subclasses :boom: + +- The `findSnapPixels` method has been removed. Use `findSettledPosition` instead. +- `SnapToNearest.snapTo` has been renamed to `anchors`. + +## Changes in `SheetPhysics` and related classes :boom: + +- The `createSettlingSimulation` method has been removed in favor of the `findSettledPosition` + method. +- As a result, `InterpolationSimulation` has also been removed since it is no longer used internally + and is not a core feature of the package. +- `SnappingSheetPhysics.snappingBehavior` has been renamed to `behavior`. + +## Changes in `SheetController` :boom: + +`SheetController` is no longer a notifier of `SheetMetrics`, and is now a notifier of the sheet +position (`double?`) instead. It is still possible to access the `SheetMetrics` object through +the `SheetController.metrics` getter. + +## Changes in `Extent` and its subclasses :boom: + +`Extent`, `FixedExtent`, and `ProportionalExtent` have been renamed +to `SheetAnchor`, `FixedSheetAnchor`, and `ProportionalSheetAnchor`, respectively. diff --git a/docs/migration-guide-0.3.x.md b/migrations/migration-guide-0.3.x.md similarity index 100% rename from docs/migration-guide-0.3.x.md rename to migrations/migration-guide-0.3.x.md diff --git a/docs/migration-guide-0.4.x.md b/migrations/migration-guide-0.4.x.md similarity index 100% rename from docs/migration-guide-0.4.x.md rename to migrations/migration-guide-0.4.x.md diff --git a/docs/migration-guide-0.5.x.md b/migrations/migration-guide-0.5.x.md similarity index 100% rename from docs/migration-guide-0.5.x.md rename to migrations/migration-guide-0.5.x.md diff --git a/docs/migration-guide-0.6.x.md b/migrations/migration-guide-0.6.x.md similarity index 100% rename from docs/migration-guide-0.6.x.md rename to migrations/migration-guide-0.6.x.md diff --git a/docs/migration-guide-0.7.x.md b/migrations/migration-guide-0.7.x.md similarity index 100% rename from docs/migration-guide-0.7.x.md rename to migrations/migration-guide-0.7.x.md diff --git a/migrations/migration-guide-0.8.x.md b/migrations/migration-guide-0.8.x.md new file mode 100644 index 00000000..eb15ff8a --- /dev/null +++ b/migrations/migration-guide-0.8.x.md @@ -0,0 +1,35 @@ +# Migration guide to 0.8.x from 0.7.x + +Here is the summary of the breaking changes included in the version 0.8.0. + +## Changes in StretchingSheetPhysics + +### 'Stretching' was renamed to 'Bouncing' + +`StretchingSheetPhysics` was renamed to `BouncingSheetPhysics` to better reflect its behavior, as it does not change the actual size of the sheet, but rather allows the sheet position to go beyond the content bounds. Accordingly, the other related classes and properties were also renamed. + +### New way to control the bouncing behavior of a sheet + +[BouncingSheetBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/BouncingSheetBehavior-class.html) was added as the new way to control the bouncing behavior of a sheet. It replaces `StretchingSheetPhysics.stretchingRange` property, which has been removed. + +**BEFORE:** + +```dart +const physics = StretchingSheetPhysics( + stretchingRange: Extent.proportional(0.1), +); +``` + +**AFTER:** + +```dart +const physics = BouncingSheetPhysics( + behavior: FixedBouncingBehavior(Extent.proportional(0.1)), +); +``` + +See also: + +- [FixedBouncingBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/FixedBouncingBehavior-class.html), which allows the sheet position to exceeds the content bounds by a fixed amount. +- [DirectionAwareBouncingBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DirectionAwareBouncingBehavior-class.html), which is similar to `FixedBouncingBehavior`, but different bounceable ranges can be set for each direction. +- [tutorial/bouncing_behaviors.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/bouncing_behaviors.dart), an interactive example of `BouncingBehavior` classes. diff --git a/migrations/migration-guide-0.9.x.md b/migrations/migration-guide-0.9.x.md new file mode 100644 index 00000000..7670193e --- /dev/null +++ b/migrations/migration-guide-0.9.x.md @@ -0,0 +1,41 @@ +# Migration guide to 0.9.x from 0.8.x + +## keyboardDismissBehavior is now a widget instead of a property + +`*Sheet.keyboardDismissBehavior` is now removed and replaced with `SheetKeyboardDismissible` widget. You can retain existing behavior by wrapping a sheet with `SheetKeyboardDismissible` and setting the same `KeyboardDismissBehavior` object to its `dismissBehavior` property. + +### Removed APIs + +- `ScrollableSheet.keyboardDismissBehavior` +- `DraggableSheet.keyboardDismissBehavior` +- `NavigationSheet.keyboardDismissBehavior` + +### Before + +```dart +DraggableSheet( + keyboardDismissBehavior: const KeyboardDismissBehavior.onDrag(), + child: Container( + color: Colors.white, + width: double.infinity, + height: 500, + ) +); +``` + +### After + +Wrap a sheet with `SheetKeyboardDismissible` widget (the same applies to `ScrollableSheet` and `NavigationSheet`). + +```dart +SheetKeyboardDismissible( + dismissBehavior: const KeyboardDismissBehavior.onDrag(), + child: DraggableSheet( + child: Container( + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), +); +``` diff --git a/package/.gitignore b/package/.gitignore deleted file mode 100644 index 6bc4ab47..00000000 --- a/package/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# Flutter/Dart/Pub related -/pubspec.lock -**/doc/api/ -.dart_tool/ -.packages -build/ diff --git a/package/.idea/other.xml b/package/.idea/other.xml new file mode 100644 index 00000000..49481ad4 --- /dev/null +++ b/package/.idea/other.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/package/.idea/vcs.xml b/package/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/package/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/package/.idea/workspace.xml b/package/.idea/workspace.xml new file mode 100644 index 00000000..f14c9797 --- /dev/null +++ b/package/.idea/workspace.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + { + "lastFilter": { + "state": "OPEN", + "assignee": "AlinaStepanova" + } +} + { + "selectedUrlAndAccountId": { + "url": "https://github.com/AlinaStepanova/smooth_sheets.git", + "accountId": "dfe3fd51-3f19-47f7-b8a2-c1e98993971b" + } +} + + + { + "associatedIndex": 2 +} + + + + + + + + + + 1714394570665 + + + + \ No newline at end of file diff --git a/package/.metadata b/package/.metadata deleted file mode 100644 index fa347fc6..00000000 --- a/package/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: f468f3366c26a5092eb964a230ce7892fda8f2f8 - channel: stable - -project_type: package diff --git a/package/LICENSE b/package/LICENSE deleted file mode 100644 index 0e89d180..00000000 --- a/package/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2023 Daichi Fujita - -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: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -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. diff --git a/package/README.md b/package/README.md deleted file mode 100644 index 9c23c0bb..00000000 --- a/package/README.md +++ /dev/null @@ -1,385 +0,0 @@ -# smooth_sheets - -[![GitHub Repo stars](https://img.shields.io/github/stars/fujidaiti/smooth_sheets)](https://github.com/fujidaiti/smooth_sheets) [![GitHub last commit (branch)](https://img.shields.io/github/last-commit/fujidaiti/smooth_sheets/main?logo=git)](https://github.com/fujidaiti/smooth_sheets/commits/main/) [![Pub Version](https://img.shields.io/pub/v/smooth_sheets)](https://pub.dev/packages/smooth_sheets) ![Pub Likes](https://img.shields.io/pub/likes/smooth_sheets) ![Pub Points](https://img.shields.io/pub/points/smooth_sheets) ![Pub Popularity](https://img.shields.io/pub/popularity/smooth_sheets) - - -**smooth_sheets** offers modal and persistent sheet widgets for Flutter apps. The key features are: - -- **Smooth motion**: The sheets respond to user interaction with smooth, graceful motion. -- **Highly flexible**: Not restricted to a specific design. Both modal and persistent styles are supp/orted, as well as scrollable and non-scrollable widgets. -- **Supports nested navigation**: A sheet is able to have multiple pages and to navigate between the pages with motion animation for transitions. -- **Works with imperative & declarative Navigator API**: No special navigation mechanism is required. The traditional ways such as `Navigator.push` is supported and it works with Navigator 2.0 packages like go_route as well. -- **iOS flavor**: The modal sheets in the style of iOS 15 are supported. - -
- -**CAUTION🚨** - -This library is currently in the experimental stage. The API may undergo changes without prior notice. For documentation of the latest published version, please visit the [package site](https://pub.dev/packages/smooth_sheets) on pub.dev. - -
- -## Migration guide - -- [0.6.x to 0.7.x](https://github.com/fujidaiti/smooth_sheets/blob/main/docs/migration-guide-0.7.x.md) 🆕 -- [0.5.x to 0.6.x](https://github.com/fujidaiti/smooth_sheets/blob/main/docs/migration-guide-0.6.x.md) - -See [here](https://github.com/fujidaiti/smooth_sheets/blob/main/docs/) for older versions. - -
- -## Showcases - - - - - - - - - - - - - - - - - - -
-

AI Playlist Generator

-

An AI assistant that helps create a music playlist based on the user's preferences. See the cookbook for more details.

-

Key components:

-
    -
  • NavigationSheet
  • -
  • ModalSheetPage
  • -
  • DraggableNavigationSheetPage
  • -
  • ScrollableNavigationSheetPage
  • -
  • SheetDismissible
  • -
-
-

Safari app

-

A practical example of ios-style modal sheets. See the cookbook for more details.

-

Key components:

-
    -
  • CupertinoStackedTransition
  • -
  • CupertinoModalSheetRoute
  • -
-
-

Airbnb mobile app clone

-

A partial clone of the Airbnb mobile app. The user can drag the house list down to reveal the map behind it. See the cookbook for more details.

-

Key components:

-
    -
  • ScrollableSheet
  • -
  • SheetPhysics
  • -
  • SheetController
  • -
  • ExtentDrivenAnimation
  • -
-
-

Todo List

-

A simple Todo app that shows how a sheet handles the on-screen keyboard. See the cookbook for more details.

-

Used components:

-
    -
  • ScrollableSheet
  • -
  • SheetContentScaffold
  • -
  • SheetKeyboardDismissBehavior
  • -
  • SheetDismissible
  • -
  • StickyBottomBarVisibility
  • -
-
- - - - -
- -## Why use this? - -There are few packages on pub.dev that supports nested navigation with motion animation for page transitions. One of the great choices for this usecase is [wolt_modal_sheet](https://github.com/woltapp/wolt_modal_sheet), which this package is inspired by. Although smooth_sheet has similar features with wolt_modal_sheet, it does not intended to be a replacement of that package. Here is some differences between those 2 packages: - -| | wolt_modal_sheet | smooth_sheets | -| :--------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | -| Design | Based on Wolt's [design guideline](https://careers.wolt.com/en/blog/tech/an-overview-of-the-multi-page-scrollable-bottom-sheet-ui-design) | Not restricted to a specific design, fully customizable | -| Navigation mechanism | [Manage the page index in ValueNotifier](https://github.com/woltapp/wolt_modal_sheet#usage) | Works with built-in Navigator API (both of imperative and declarative) | -| Scrollable content | [Supported](https://github.com/woltapp/wolt_modal_sheet#scrollable-content) | Supported | -| Persistent sheets | Not supported | Supported | -| Screen size adaptation | [The sheet appears as a dialog on large screens](https://github.com/woltapp/wolt_modal_sheet#responsive-design) | Not supported | - -
- -## Usage - -Several resources are available for learning the functionalities of this package. - -- Tutorials: See [cookbook/lib/tutorial/](https://github.com/fujidaiti/smooth_sheets/tree/main/cookbook/lib/tutorial) to learn the basic usage of the core components. -- Showcases: More practical examples are available in [cookbook/lib/showcase/](https://github.com/fujidaiti/smooth_sheets/tree/main/cookbook/lib/showcase). -- Documentation: WORK IN PROGRESS! Please see the source code for a while. - -
- -## Ingredients - -This section provides descriptions for each core component and links to related resources for further learning. - -
- -### Extent - -Extent represents the visible height of the sheet. It is used in a variety of situations, for example, to specify how much area of a sheet is initially visible at first build, or to limit the range of sheet dragging. - -
- -### DraggableSheet - -
- -
- - -A sheet that can be dragged. The height will be equal to the content. The behavior of the sheet when over-dragged or under-dragged is determined by [SheetPhysics](#sheetphysics). Note that this widget does not work with scrollable widgets. Instead, use [ScrollableSheet](#scrollablesheet) for this usecase. - - - -See also: - -- [draggable_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/draggable_sheet.dart) for basic usage. - -
- -### ScrollableSheet - -
- -
- - -A sheet that is similar to [DraggableSheet](#draggablesheet), but specifically designed to be integrated with scrollable widgets. It will begin to be dragged when the content is over-scrolled or under-scrolled. - - - -See also: - -- [scrollable_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/scrollable_sheet.dart) for basic usage. - -
- -### NavigationSheet - -
- -
- - -A sheet that is able to have multiple pages and performs graceful motion animation when page transitions. It supports both of imperative Navigator API such as `Navigator.push`, and declarative API (Navigator 2.0). - - - -See also: - -- [declarative_navigation_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/declarative_navigation_sheet.dart), tutorial of integration with Navigator 2.0 using [go_router](https://pub.dev/packages/go_router) package. -- [imperative_navigation_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/imperative_navigation_sheet.dart), a tutorial of integration with imperative Navigator API. - -
- -### ModalSheets - -
- -
- - - - -A sheet can be displayed as a modal sheet using ModalSheetRoute for imperative navigation, or ModalSheetPage for declarative navigation. To enable the *swipe-to-dismiss* action, which allows the user to dismiss the sheet by a swiping-down gesture, set `swipeDismissible` to true. - -
- -
- -Furthermore, [the modal sheets in the style of iOS 15](https://medium.com/surf-dev/bottomsheet-in-ios-15-uisheetpresentationcontroller-and-its-capabilities-5e913661c9f) are also supported. For imperative navigation, use CupertinoModalSheetRoute, and for declarative navigation, use CupertinoModalSheetPage, respectively. - - - - -See also: - -- [declarative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/declarative_modal_sheet.dart), a tutorial of integration with declarative navigation using [go_router](https://pub.dev/packages/go_router) package. -- [imperative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/imperative_modal_sheet.dart), a tutorial of integration with imperative Navigator API. -- [cupertino_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/cupertino_modal_sheet.dart), a tutorial of iOS style modal sheets. -- [showcase/todo_list](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/showcase/todo_list), which uses SheetDismissible to show a confirmation dialog when the user tries to discard the todo editing sheet without saving the content. - -
- -### SheetPhysics - -
- -
- - -A physics determines how the sheet will behave when over-dragged or under-dragged, or when the user stops dragging. There are 3 predefined physics: - -- ClampingSheetPhysics: Prevents the sheet from reaching beyond the draggable bounds -- StretchingSheetPhysics: Allows the sheet to go beyond the draggable bounds, but then bounce the sheet back to the edge of those bounds -- SnappingSheetPhysics: Automatically snaps the sheet to a certain extent when the user stops dragging - -These physics can be combined to create more complex behavior (e.g. stretching behavior + snapping behavior). - - - -See also: - -- [sheet_physics.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/sheet_physics.dart) for basic usage. - -
- -### SheetController - -
- -
- - -Like [ScrollController](https://api.flutter.dev/flutter/widgets/ScrollController-class.html) for scrollable widget, the SheetController can be used to animate or observe the extent of a sheet. - - - -See also: - -- [sheet_controller.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/sheet_controller.dart) for basic usage. - -
- -### SheetContentScaffold - -
- -
- - -A special kind of [Scaffold](https://api.flutter.dev/flutter/material/Scaffold-class.html) designed for use in a sheet. It has slots for an app bar and a bottom bar, similar to Scaffold. However, it differs in that its height reduces to fit the content widget. - - - -See also: - -- [SheetContentScaffold](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetContentScaffold-class.html), the API documentation. -- [BottomBarVisibility](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/BottomBarVisibility-class.html), which can be used to control the visibility of the bottom bar based on the sheet position. -- [tutorial/sheet_content_scaffold.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/sheet_content_scaffold.dart), which shows the basic usage of SheetContentScaffold. -- [tutorial/bottom_bar_visibility.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/bottom_bar_visibility.dart), which shows the basic usage of BottomBarVisibility widgets. - -
- -### SheetDraggable - -
- -
- -SheetDraggable enables its child widget to act as a drag handle for the sheet. Typically, you will want to use this widget when placing non-scrollable widget(s) in a [ScrollableSheet](#scrollablesheet), since it only works with scrollable widgets, so you can't drag the sheet by touching a non-scrollable area. Try removing SheetDraggable and you will see that the drag handle doesn't work as it should. -Note that SheetDraggable is not needed when using DraggableSheet since it implicitly wraps the child widget with SheetDraggable. - - - -See also: - -- [sheet_draggable.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/sheet_draggable.dart) for basic usage. - -
- -### ExtentDrivenAnimation - -
- -
- -It is easy to create sheet extent driven animations by using ExtentDrivenAnimation, a special kind of [Animation](https://api.flutter.dev/flutter/animation/Animation-class.html) whose value changes from 0 to 1 as the sheet extent changes from 'startExtent' to 'endExtent'. - - - -See also: - -- [extent_driven_animation](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/extent_driven_animation.dart) for basic usage. -- [airbnb_mobile_app.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/showcase/airbnb_mobile_app.dart), which show how ExtentDrivenAnimation can be used to hide the bottom navigation bar and a FAB when the sheet is dragged down, and to show them when the sheet is dragged up again. - -
- -### SheetNotification - -A sheet dispatches a [SheetNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetNotification-class.html) when its extent changes. This can be used to observe the extent of a descendant sheet from an ancestor widget. - -```dart -NotificationListener( - onNotification: (notification) { - debugPrint('${notification.metrics}'); - return false; - }, - child: DraggableSheet(...), -), -``` - - - -See also: - -- [SheetDragUpdateNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetDragUpdateNotification-class.html), which is dispatched when the sheet is dragged by the user. -- [SheetUpdateNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetUpdateNotification-class.html), which is dispatched when the sheet extent is updated by other than user interaction such as animation. -- [SheetOverflowNotification](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetOverflowNotification-class.html), which is dispatched when the user tries to drag the sheet beyond its draggable bounds but the sheet has not changed its extent because its [SheetPhysics](#sheetphysics) does not allow it to be. -- [NotificationListener](https://api.flutter.dev/flutter/widgets/NotificationListener-class.html), which can be used to listen for the notifications in a subtree. - -
- -### SheetKeyboardDismissBehavior - -
- -
-
- -[SheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SheetKeyboardDismissBehavior-class.html) determines when the sheet should dismiss the on-screen keyboard. This feature is similar to [ScrollViewKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/ScrollViewKeyboardDismissBehavior.html) for scrollable widgets. - -Although it is easy to create custom behaviors by implementing SheetKeyboardDismissBehavior interface, there are 3 types of predefined behaviors for convenience. - -- [DragSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard when the sheet is dragged. -- [DragDownSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragDownSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard only when the sheet is dragged down. -- [DragUpSheetKeyboardDismissBehavior](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/DragUpSheetKeyboardDismissBehavior-class.html), which always dismisses the on-screen keyboard only when the sheet is dragged up. - - - -See also: - -- [tutorial/keyboard_dismiss_behavior.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/keyboard_dismiss_behavior.dart) for basic usage. - -
- -## Questions - -If you have any questions, feel free to ask them on [the discussions page](https://github.com/fujidaiti/smooth_sheets/discussions). - -
- -## Contributing - -Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. - -If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". -Don't forget to give the project a star! Thanks again! - -1. Fork the Project -2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) -3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the Branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -
- -## Links - -- [Roadmap](https://github.com/fujidaiti/smooth_sheets/issues?q=is%3Aissue+is%3Aopen+label%3A%22feature+request%22) -- [API Documentation](https://pub.dev/documentation/smooth_sheets/latest/) -- [pub.dev](https://pub.dev/packages/smooth_sheets) -- [norelease.dev](https://pub.dev/publishers/norelease.dev/packages) - -
- diff --git a/package/example/.metadata b/package/example/.metadata deleted file mode 100644 index a778330b..00000000 --- a/package/example/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "d211f42860350d914a5ad8102f9ec32764dc6d06" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: android - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: ios - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: linux - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: macos - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: web - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: windows - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/package/example/README.md b/package/example/README.md deleted file mode 100644 index 0e84df66..00000000 --- a/package/example/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Examples - -Many examples are available in the [cookbook](https://github.com/fujidaiti/smooth_sheets/tree/main/cookbook/lib). diff --git a/package/lib/src/draggable/draggable_sheet.dart b/package/lib/src/draggable/draggable_sheet.dart deleted file mode 100644 index 76d2e61f..00000000 --- a/package/lib/src/draggable/draggable_sheet.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../foundation/keyboard_dismissible.dart'; -import '../foundation/sheet_controller.dart'; -import '../foundation/sheet_extent.dart'; -import '../foundation/sheet_gesture_tamperer.dart'; -import '../foundation/sheet_physics.dart'; -import '../foundation/sheet_theme.dart'; -import '../foundation/sheet_viewport.dart'; -import '../scrollable/scrollable_sheet.dart'; -import 'draggable_sheet_extent.dart'; -import 'sheet_draggable.dart'; - -/// A sheet that can be dragged. -/// -/// Note that this widget does not work with scrollable widgets. -/// Instead, use [ScrollableSheet] for this usecase. -class DraggableSheet extends StatelessWidget { - /// Creates a sheet that can be dragged. - /// - /// The maximum height will be equal to the [child]'s height. - /// - /// The [physics] determines how the sheet will behave when over-dragged - /// or under-dragged, or when the user stops dragging. - /// - /// The [hitTestBehavior] defaults to [HitTestBehavior.translucent]. - /// - /// See also: - /// - [A tutorial](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/draggable_sheet.dart), - /// minimal code to use a draggable sheet. - const DraggableSheet({ - super.key, - this.hitTestBehavior = HitTestBehavior.translucent, - this.keyboardDismissBehavior, - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), - this.physics, - required this.child, - this.controller, - }); - - /// The strategy to dismiss the on-screen keyboard when the sheet is dragged. - final SheetKeyboardDismissBehavior? keyboardDismissBehavior; - - final Extent initialExtent; - - /// {@macro SheetExtentConfig.minExtent} - final Extent minExtent; - - /// {@macro SheetExtentConfig.maxExtent} - final Extent maxExtent; - - /// {@macro SheetExtentConfig.physics} - final SheetPhysics? physics; - - /// An object that can be used to control and observe the sheet height. - final SheetController? controller; - - /// The content of the sheet. - final Widget child; - - /// How to behave during hit testing. - /// - /// This value will be passed to the constructor of internal [SheetDraggable]. - final HitTestBehavior hitTestBehavior; - - @override - Widget build(BuildContext context) { - final theme = SheetTheme.maybeOf(context); - final physics = this.physics ?? theme?.physics ?? kDefaultSheetPhysics; - final keyboardDismissBehavior = - this.keyboardDismissBehavior ?? theme?.keyboardDismissBehavior; - final gestureTamper = TamperSheetGesture.maybeOf(context); - - Widget result = ImplicitSheetControllerScope( - controller: controller, - builder: (context, controller) { - return SheetExtentScope( - controller: controller, - factory: const DraggableSheetExtentFactory(), - config: DraggableSheetExtentConfig( - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, - physics: physics, - gestureTamperer: gestureTamper, - debugLabel: 'DraggableSheet', - ), - child: SheetViewport( - child: SheetContentViewport( - child: SheetDraggable( - behavior: hitTestBehavior, - child: child, - ), - ), - ), - ); - }, - ); - - if (keyboardDismissBehavior != null) { - result = SheetKeyboardDismissible( - dismissBehavior: keyboardDismissBehavior, - child: result, - ); - } - - return result; - } -} diff --git a/package/lib/src/draggable/draggable_sheet_extent.dart b/package/lib/src/draggable/draggable_sheet_extent.dart deleted file mode 100644 index b5f263b0..00000000 --- a/package/lib/src/draggable/draggable_sheet_extent.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:meta/meta.dart'; - -import '../foundation/sheet_extent.dart'; - -@internal -class DraggableSheetExtentFactory extends SheetExtentFactory< - DraggableSheetExtentConfig, DraggableSheetExtent> { - const DraggableSheetExtentFactory(); - - @override - DraggableSheetExtent createSheetExtent({ - required SheetContext context, - required DraggableSheetExtentConfig config, - }) { - return DraggableSheetExtent(context: context, config: config); - } -} - -@internal -class DraggableSheetExtentConfig extends SheetExtentConfig { - const DraggableSheetExtentConfig({ - required this.initialExtent, - required super.minExtent, - required super.maxExtent, - required super.physics, - required super.gestureTamperer, - super.debugLabel, - }); - - final Extent initialExtent; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is DraggableSheetExtentConfig && - other.initialExtent == initialExtent && - super == other; - } - - @override - int get hashCode => Object.hash(initialExtent, super.hashCode); -} - -@internal -class DraggableSheetExtent extends SheetExtent { - DraggableSheetExtent({ - required super.context, - required super.config, - }); - - @override - void applyNewContentSize(Size contentSize) { - super.applyNewContentSize(contentSize); - if (metrics.maybePixels == null) { - setPixels(config.initialExtent.resolve(metrics.contentSize)); - } - } -} diff --git a/package/lib/src/foundation/sheet_activity.dart b/package/lib/src/foundation/sheet_activity.dart deleted file mode 100644 index 1c86ff9e..00000000 --- a/package/lib/src/foundation/sheet_activity.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import 'sheet_drag.dart'; -import 'sheet_extent.dart'; -import 'sheet_status.dart'; - -@internal -@optionalTypeArgs -abstract class SheetActivity { - bool _disposed = false; - bool get disposed { - assert(!_mounted || !_disposed); - return _disposed; - } - - bool _mounted = false; - bool get mounted { - assert(!_mounted || !_disposed); - return _mounted; - } - - T? _owner; - T get owner { - assert(debugAssertMounted()); - return _owner!; - } - - double get velocity => 0.0; - - SheetStatus get status; - - @mustCallSuper - void init(T owner) { - assert(_owner == null); - assert(!_mounted); - assert(!_disposed); - - _owner = owner; - _mounted = true; - } - - @mustCallSuper - void updateOwner(T owner) { - _owner = owner; - } - - void dispose() { - _mounted = false; - _disposed = true; - } - - bool isCompatibleWith(SheetExtent newOwner) => newOwner is T; - - void didChangeContentSize(Size? oldSize) {} - - void didChangeViewportDimensions(Size? oldSize, EdgeInsets? oldInsets) {} - - void didChangeBoundaryConstraints( - double? oldMinPixels, - double? oldMaxPixels, - ) {} - - void didFinalizeDimensions( - Size? oldContentSize, - Size? oldViewportSize, - EdgeInsets? oldViewportInsets, - ) { - if (oldContentSize == null && oldViewportSize == null) { - // The sheet was laid out, but not changed in size. - return; - } - - final metrics = owner.metrics; - final oldPixels = metrics.pixels; - final newInsets = metrics.viewportInsets; - final oldInsets = oldViewportInsets ?? newInsets; - final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; - - switch (deltaInsetBottom) { - case > 0: - // Prevents the sheet from being pushed off the screen by the keyboard. - final correction = min(0.0, metrics.maxViewPixels - metrics.viewPixels); - owner.setPixels(oldPixels + correction); - - case < 0: - // Appends the delta of the bottom inset (typically the keyboard height) - // to keep the visual sheet position unchanged. - owner.setPixels( - min(oldPixels - deltaInsetBottom, owner.metrics.maxPixels), - ); - } - - owner.settle(); - } - - @protected - bool debugAssertMounted() { - assert(() { - if (!mounted) { - throw FlutterError( - 'A $runtimeType was used after being disposed, or ' - 'before init() was called. Once you have called dispose() ' - 'on a $runtimeType, it can no longer be used.', - ); - } - return true; - }()); - return true; - } - - @protected - bool debugAssertNotDisposed() { - assert(() { - if (disposed) { - throw FlutterError( - 'A $runtimeType was used after being disposed. Once you have ' - 'called dispose() on a $runtimeType, it can no longer be used.', - ); - } - return true; - }()); - return true; - } -} - -@internal -class AnimatedSheetActivity extends SheetActivity - with ControlledSheetActivityMixin { - AnimatedSheetActivity({ - required this.from, - required this.to, - required this.duration, - required this.curve, - }) : assert(duration > Duration.zero); - - final double from; - final double to; - final Duration duration; - final Curve curve; - - @override - AnimationController createAnimationController() { - return AnimationController.unbounded( - value: from, vsync: owner.context.vsync); - } - - @override - TickerFuture onAnimationStart() { - return controller.animateTo(to, duration: duration, curve: curve); - } - - @override - void onAnimationEnd() { - owner.goBallistic(0); - } -} - -@internal -class BallisticSheetActivity extends SheetActivity - with ControlledSheetActivityMixin { - BallisticSheetActivity({ - required this.simulation, - }); - - final Simulation simulation; - - @override - AnimationController createAnimationController() { - return AnimationController.unbounded(vsync: owner.context.vsync); - } - - @override - TickerFuture onAnimationStart() { - return controller.animateWith(simulation); - } - - @override - void onAnimationEnd() { - owner.goBallistic(0); - } -} - -@internal -class IdleSheetActivity extends SheetActivity { - @override - SheetStatus get status => SheetStatus.stable; -} - -@internal -class DragSheetActivity extends SheetActivity - with UserControlledSheetActivityMixin - implements SheetDragControllerTarget { - DragSheetActivity(); - - @override - VerticalDirection get dragAxisDirection => VerticalDirection.up; - - @override - Offset computeMinPotentialDeltaConsumption(Offset delta) { - final metrics = owner.metrics; - - switch (delta.dy) { - case > 0: - final draggableDistance = max(0.0, metrics.maxPixels - metrics.pixels); - return Offset(delta.dx, min(draggableDistance, delta.dy)); - - case < 0: - final draggableDistance = max(0.0, metrics.pixels - metrics.minPixels); - return Offset(delta.dx, max(-1 * draggableDistance, delta.dy)); - - case _: - return delta; - } - } - - @override - void applyUserDragUpdate(Offset offset) { - final physicsAppliedDelta = - owner.config.physics.applyPhysicsToOffset(offset.dy, owner.metrics); - if (physicsAppliedDelta != 0) { - owner.setPixels(owner.metrics.pixels + physicsAppliedDelta); - } - } - - @override - void applyUserDragEnd(Velocity velocity) { - owner.goBallistic(velocity.pixelsPerSecond.dy); - } -} - -@internal -@optionalTypeArgs -mixin ControlledSheetActivityMixin on SheetActivity { - late final AnimationController controller; - late double _lastAnimatedValue; - - final _completer = Completer(); - Future get done => _completer.future; - - @factory - AnimationController createAnimationController(); - TickerFuture onAnimationStart(); - void onAnimationEnd() {} - - @override - double get velocity => controller.velocity; - - @override - SheetStatus get status => SheetStatus.animating; - - @override - void init(T delegate) { - super.init(delegate); - controller = createAnimationController()..addListener(onAnimationTick); - // Won't trigger if we dispose 'animation' first. - onAnimationStart().whenComplete(onAnimationEnd); - _lastAnimatedValue = controller.value; - } - - void onAnimationTick() { - if (mounted) { - final oldPixels = owner.metrics.pixels; - owner.setPixels(oldPixels + controller.value - _lastAnimatedValue); - _lastAnimatedValue = controller.value; - } - } - - @override - void dispose() { - controller.dispose(); - _completer.complete(); - super.dispose(); - } -} - -@internal -@optionalTypeArgs -mixin UserControlledSheetActivityMixin - on SheetActivity { - @override - SheetStatus get status => SheetStatus.dragging; - - @override - void didFinalizeDimensions( - Size? oldContentSize, - Size? oldViewportSize, - EdgeInsets? oldViewportInsets, - ) { - assert(owner.metrics.hasDimensions); - - final newInsets = owner.metrics.viewportInsets; - final oldInsets = oldViewportInsets ?? newInsets; - final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; - // Appends the delta of the bottom inset (typically the keyboard height) - // to keep the visual sheet position unchanged. - owner.setPixels(owner.metrics.pixels - deltaInsetBottom); - // We don't call `goSettling` here because the user is still - // manually controlling the sheet position. - } -} diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart deleted file mode 100644 index 0e37a94b..00000000 --- a/package/lib/src/foundation/sheet_extent.dart +++ /dev/null @@ -1,1036 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import '../internal/double_utils.dart'; -import 'sheet_activity.dart'; -import 'sheet_controller.dart'; -import 'sheet_drag.dart'; -import 'sheet_gesture_tamperer.dart'; -import 'sheet_notification.dart'; -import 'sheet_physics.dart'; -import 'sheet_status.dart'; - -/// A representation of a visible height of the sheet. -/// -/// It is used in a variety of situations, for example, to specify -/// how much area of a sheet is initially visible at first build, -/// or to limit the range of sheet dragging. -/// -/// See also: -/// - [ProportionalExtent], which is proportional to the content height. -/// - [FixedExtent], which is defined by a concrete value in pixels. -abstract interface class Extent { - /// {@macro fixed_extent} - const factory Extent.pixels(double pixels) = FixedExtent; - - /// {@macro proportional_extent} - const factory Extent.proportional(double size) = ProportionalExtent; - - /// Resolves the extent to a concrete value in pixels. - /// - /// Do not cache the value of [contentSize] because - /// it may change over time. - double resolve(Size contentSize); -} - -/// An extent that is proportional to the content height. -class ProportionalExtent implements Extent { - /// {@template proportional_extent} - /// Creates an extent that is proportional to the content height. - /// - /// The [factor] must be greater than or equal to 0. - /// This extent will resolve to `contentSize.height * factor`. - /// {@endtemplate} - const ProportionalExtent(this.factor) : assert(factor >= 0); - - /// The fraction of the content height. - final double factor; - - @override - double resolve(Size contentSize) => contentSize.height * factor; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ProportionalExtent && - runtimeType == other.runtimeType && - factor == other.factor); - - @override - int get hashCode => Object.hash(runtimeType, factor); -} - -/// An extent that has an concrete value in pixels. -class FixedExtent implements Extent { - /// {@template fixed_extent} - /// Creates an extent from a concrete value in pixels. - /// {@endtemplate} - const FixedExtent(this.pixels) : assert(pixels >= 0); - - /// The value in pixels. - final double pixels; - - @override - double resolve(Size contentSize) => pixels; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FixedExtent && - runtimeType == other.runtimeType && - pixels == other.pixels); - - @override - int get hashCode => Object.hash(runtimeType, pixels); -} - -/// Manages the extent of a sheet. -/// -/// This object is much like [ScrollPosition] for scrollable widgets. -/// The [SheetMetrics.pixels] value determines the visible height of a sheet. -/// As this value changes, the sheet translates its position, which changes the -/// visible area of the content. The [SheetMetrics.minPixels] and -/// [SheetMetrics.maxPixels] values limit the range of the *pixels*, but it can -/// be outside of the range if the [SheetExtentConfig.physics] allows it. -/// -/// The current [activity] is responsible for how the *pixels* changes -/// over time, for example, [AnimatedSheetActivity] animates the *pixels* to -/// a target value, and [IdleSheetActivity] keeps the *pixels* unchanged. -/// [SheetExtent] starts with [IdleSheetActivity] as the initial activity, -/// and it can be changed by calling [beginActivity]. -/// -/// This object is [Listenable] that notifies its listeners when *pixels* -/// changes, even during build or layout phase. For listeners that can cause -/// any widget to rebuild, consider using [SheetController], which is also -/// [Listenable] of the extent, but only notifies its listeners between frames. -/// -/// See also: -/// - [SheetController], which can be attached to a sheet to control its extent. -/// - [SheetExtentScope], which creates a [SheetExtent], manages its lifecycle, -/// and exposes it to the descendant widgets. -@internal -@optionalTypeArgs -abstract class SheetExtent extends ChangeNotifier - implements ValueListenable { - /// Creates an object that manages the extent of a sheet. - SheetExtent({ - required this.context, - required T config, - }) : _config = config { - goIdle(); - } - - @override - SheetMetrics get value => metrics; - - SheetStatus get status => activity.status; - - /// A handle to the owner of this object. - final SheetContext context; - - T get config => _config; - T _config; - - /// Snapshot of the current sheet's state. - SheetMetrics get metrics => _metrics; - SheetMetrics _metrics = SheetMetrics.empty; - - /// The current activity of the sheet. - SheetActivity get activity => _activity!; - SheetActivity? _activity; - - /// The current drag that is currently driving the sheet. - /// - /// Intentionally exposed so that a subclass can override - /// the default implementation of [drag]. - @protected - SheetDragController? currentDrag; - - @mustCallSuper - void takeOver(SheetExtent other) { - assert(currentDrag == null); - if (other.activity.isCompatibleWith(this)) { - activity.dispose(); - _activity = other.activity; - // This is necessary to prevent the activity from being disposed of - // when `other` extent is disposed of. - other._activity = null; - activity.updateOwner(this); - - if ((other.currentDrag, activity) - case (final drag?, final SheetDragControllerTarget dragActivity)) { - currentDrag = drag..updateTarget(dragActivity); - other.currentDrag = null; - } - } else { - goIdle(); - } - if (other.metrics.maybePixels case final pixels?) { - correctPixels(pixels); - } - applyNewViewportDimensions( - other.metrics.viewportSize, - other.metrics.viewportInsets, - ); - applyNewContentSize(other.metrics.contentSize); - } - - @mustCallSuper - void applyNewContentSize(Size contentSize) { - if (metrics.maybeContentSize != contentSize) { - final oldMaxPixels = metrics.maybeMaxPixels; - final oldMinPixels = metrics.maybeMinPixels; - _oldContentSize = metrics.maybeContentSize; - _metrics = metrics.copyWith( - contentSize: contentSize, - minPixels: config.minExtent.resolve(contentSize), - maxPixels: config.maxExtent.resolve(contentSize), - ); - activity.didChangeContentSize(_oldContentSize); - if (oldMinPixels != metrics.minPixels || - oldMaxPixels != metrics.maxPixels) { - activity.didChangeBoundaryConstraints(oldMinPixels, oldMaxPixels); - } - } - } - - @mustCallSuper - void applyNewViewportDimensions(Size size, EdgeInsets insets) { - if (metrics.maybeViewportSize != size || - metrics.maybeViewportInsets != insets) { - _oldViewportSize = metrics.maybeViewportSize; - _oldViewportInsets = metrics.maybeViewportInsets; - _metrics = metrics.copyWith(viewportSize: size, viewportInsets: insets); - activity.didChangeViewportDimensions( - _oldViewportSize, - _oldViewportInsets, - ); - } - } - - @mustCallSuper - void applyNewConfig(T config) { - if (_config != config) { - _config = config; - currentDrag?.updateGestureTamperer(config.gestureTamperer); - final oldMaxPixels = metrics.maxPixels; - final oldMinPixels = metrics.minPixels; - _metrics = metrics.copyWith( - minPixels: config.minExtent.resolve(metrics.contentSize), - maxPixels: config.maxExtent.resolve(metrics.contentSize), - ); - if (oldMinPixels != metrics.minPixels || - oldMaxPixels != metrics.maxPixels) { - activity.didChangeBoundaryConstraints(oldMinPixels, oldMaxPixels); - } - } - } - - Size? _oldContentSize; - Size? _oldViewportSize; - EdgeInsets? _oldViewportInsets; - int _markAsDimensionsWillChangeCallCount = 0; - - @mustCallSuper - void markAsDimensionsWillChange() { - assert(() { - if (_markAsDimensionsWillChangeCallCount == 0) { - // Ensure that the number of calls to markAsDimensionsWillChange() - // matches the number of calls to markAsDimensionsChanged(). - WidgetsBinding.instance.addPostFrameCallback((_) { - assert( - _markAsDimensionsWillChangeCallCount == 0, - _markAsDimensionsWillChangeCallCount > 0 - ? _debugMessage( - 'markAsDimensionsWillChange() was called more times ' - 'than markAsDimensionsChanged() in a frame.', - ) - : _debugMessage( - 'markAsDimensionsChanged() was called more times ' - 'than markAsDimensionsWillChange() in a frame.', - ), - ); - }); - } - return true; - }()); - - if (_markAsDimensionsWillChangeCallCount == 0) { - _oldContentSize = null; - _oldViewportSize = null; - _oldViewportInsets = null; - } - - _markAsDimensionsWillChangeCallCount++; - } - - @mustCallSuper - void markAsDimensionsChanged() { - assert( - _markAsDimensionsWillChangeCallCount > 0, - _debugMessage( - 'markAsDimensionsChanged() called without ' - 'a matching call to markAsDimensionsWillChange().', - ), - ); - - _markAsDimensionsWillChangeCallCount--; - if (_markAsDimensionsWillChangeCallCount == 0) { - onDimensionsFinalized(); - } - } - - @mustCallSuper - void onDimensionsFinalized() { - assert( - _markAsDimensionsWillChangeCallCount == 0, - _debugMessage( - 'Do not call this method until all dimensions changes are finalized.', - ), - ); - assert( - metrics.hasDimensions, - _debugMessage( - 'All the dimension values must be finalized ' - 'at the time onDimensionsFinalized() is called.', - ), - ); - - _activity!.didFinalizeDimensions( - _oldContentSize, - _oldViewportSize, - _oldViewportInsets, - ); - - _oldContentSize = null; - _oldViewportSize = null; - _oldViewportInsets = null; - } - - @mustCallSuper - void beginActivity(SheetActivity activity) { - final oldActivity = _activity; - // Update the current activity before initialization. - _activity = activity; - activity.init(this); - - if (oldActivity == null) { - return; - } - - final wasDragging = oldActivity.status == SheetStatus.dragging; - final isDragging = activity.status == SheetStatus.dragging; - - // TODO: Make more typesafe - switch ((wasDragging, isDragging)) { - case (true, true): - assert(currentDrag != null); - assert(activity is SheetDragControllerTarget); - currentDrag!.updateTarget(activity as SheetDragControllerTarget); - - case (true, false): - assert(currentDrag != null); - dispatchDragEndNotification(); - currentDrag!.dispose(); - currentDrag = null; - - case (false, true): - assert(currentDrag != null); - dispatchDragStartNotification(); - - case (false, false): - assert(currentDrag == null); - } - - oldActivity.dispose(); - } - - void goIdle() { - beginActivity(IdleSheetActivity()); - } - - void goBallistic(double velocity) { - assert(metrics.hasDimensions); - final simulation = - config.physics.createBallisticSimulation(velocity, metrics); - if (simulation != null) { - goBallisticWith(simulation); - } else { - goIdle(); - } - } - - void goBallisticWith(Simulation simulation) { - beginActivity(BallisticSheetActivity(simulation: simulation)); - } - - void settle() { - assert(metrics.hasDimensions); - final simulation = config.physics.createSettlingSimulation(metrics); - if (simulation != null) { - // TODO: Begin a SettlingSheetActivity - goBallisticWith(simulation); - } else { - goIdle(); - } - } - - Drag drag( - DragStartDetails details, - VoidCallback dragCancelCallback, - ) { - assert(currentDrag == null); - final dragActivity = DragSheetActivity(); - var startDetails = SheetDragStartDetails( - sourceTimeStamp: details.sourceTimeStamp, - axisDirection: dragActivity.dragAxisDirection, - localPositionX: details.localPosition.dx, - localPositionY: details.localPosition.dy, - globalPositionX: details.globalPosition.dx, - globalPositionY: details.globalPosition.dy, - kind: details.kind, - ); - if (config.gestureTamperer case final tamperer?) { - startDetails = tamperer.tamperWithDragStart(startDetails); - } - - final drag = currentDrag = SheetDragController( - target: dragActivity, - gestureTamperer: config.gestureTamperer, - details: startDetails, - onDragCanceled: dragCancelCallback, - // TODO: Specify a correct value. - carriedVelocity: 0, - motionStartDistanceThreshold: - config.physics.dragStartDistanceMotionThreshold, - ); - beginActivity(dragActivity); - return drag; - } - - @override - void dispose() { - _activity?.dispose(); - currentDrag?.dispose(); - _activity = null; - currentDrag = null; - super.dispose(); - } - - void setPixels(double pixels) { - final oldPixels = metrics.maybePixels; - correctPixels(pixels); - if (oldPixels != pixels) { - notifyListeners(); - if (currentDrag?.lastDetails is SheetDragUpdateDetails) { - dispatchDragUpdateNotification(); - } else { - dispatchUpdateNotification(); - } - } - } - - void correctPixels(double pixels) { - if (metrics.maybePixels != pixels) { - _metrics = metrics.copyWith(pixels: pixels); - } - } - - /// Animates the extent to the given value. - /// - /// The returned future completes when the animation ends, - /// whether it completed successfully or whether it was - /// interrupted prematurely. - Future animateTo( - Extent newExtent, { - Curve curve = Curves.easeInOut, - Duration duration = const Duration(milliseconds: 300), - }) { - assert(metrics.hasDimensions); - final destination = newExtent.resolve(metrics.contentSize); - if (metrics.pixels == destination) { - return Future.value(); - } else { - final activity = AnimatedSheetActivity( - from: metrics.pixels, - to: destination, - duration: duration, - curve: curve, - ); - - beginActivity(activity); - return activity.done; - } - } - - void dispatchUpdateNotification() { - if (metrics.hasDimensions) { - _dispatchNotification( - SheetUpdateNotification( - metrics: metrics, - status: status, - ), - ); - } - } - - void dispatchDragStartNotification() { - assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragStartDetails); - _dispatchNotification( - SheetDragStartNotification( - metrics: metrics, - dragDetails: details as SheetDragStartDetails, - ), - ); - } - - void dispatchDragEndNotification() { - assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragEndDetails); - _dispatchNotification( - SheetDragEndNotification( - metrics: metrics, - dragDetails: details as SheetDragEndDetails, - ), - ); - } - - void dispatchDragUpdateNotification() { - assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragUpdateDetails); - _dispatchNotification( - SheetDragUpdateNotification( - metrics: metrics, - dragDetails: details as SheetDragUpdateDetails, - ), - ); - } - - void dispatchOverflowNotification(double overflow) { - assert(metrics.hasDimensions); - _dispatchNotification( - SheetOverflowNotification( - metrics: metrics, - status: status, - overflow: overflow, - ), - ); - } - - void _dispatchNotification(SheetNotification notification) { - // Avoid dispatching a notification in the middle of a build. - switch (SchedulerBinding.instance.schedulerPhase) { - case SchedulerPhase.postFrameCallbacks: - notification.dispatch(context.notificationContext); - case SchedulerPhase.idle: - case SchedulerPhase.midFrameMicrotasks: - case SchedulerPhase.persistentCallbacks: - case SchedulerPhase.transientCallbacks: - SchedulerBinding.instance.addPostFrameCallback((_) { - notification.dispatch(context.notificationContext); - }); - } - } - - String _debugMessage(String message) { - return switch (config.debugLabel) { - null => message, - final debugLabel => '$debugLabel: $message', - }; - } -} - -@internal -class SheetExtentConfig { - const SheetExtentConfig({ - required this.minExtent, - required this.maxExtent, - required this.physics, - required this.gestureTamperer, - this.debugLabel, - }); - - /// {@template SheetExtentConfig.minExtent} - /// The minimum extent of the sheet. - /// - /// The sheet may below this extent if the [physics] allows it. - /// {@endtemplate} - final Extent minExtent; - - /// {@template SheetExtentConfig.maxExtent} - /// The maximum extent of the sheet. - /// - /// The sheet may exceed this extent if the [physics] allows it. - /// {@endtemplate} - final Extent maxExtent; - - /// {@template SheetExtentConfig.physics} - /// How the sheet extent should respond to user input. - /// - /// This determines how the sheet will behave when over-dragged or - /// under-dragged, or when the user stops dragging. - /// {@endtemplate} - final SheetPhysics physics; - - final SheetGestureTamperer? gestureTamperer; - - /// A label that is used to identify this object in debug output. - final String? debugLabel; - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other is SheetExtentConfig && - runtimeType == other.runtimeType && - minExtent == other.minExtent && - maxExtent == other.maxExtent && - physics == other.physics && - gestureTamperer == other.gestureTamperer && - debugLabel == other.debugLabel); - } - - @override - int get hashCode => Object.hash( - runtimeType, - minExtent, - maxExtent, - physics, - gestureTamperer, - debugLabel, - ); -} - -/// An immutable snapshot of the sheet's state. -class SheetMetrics { - /// Creates an immutable snapshot of the sheet's state. - const SheetMetrics({ - required double? pixels, - required double? minPixels, - required double? maxPixels, - required Size? contentSize, - required Size? viewportSize, - required EdgeInsets? viewportInsets, - }) : maybePixels = pixels, - maybeMinPixels = minPixels, - maybeMaxPixels = maxPixels, - maybeContentSize = contentSize, - maybeViewportSize = viewportSize, - maybeViewportInsets = viewportInsets; - - static const empty = SheetMetrics( - pixels: null, - minPixels: null, - maxPixels: null, - contentSize: null, - viewportSize: null, - viewportInsets: null, - ); - - final double? maybePixels; - final double? maybeMinPixels; - final double? maybeMaxPixels; - final Size? maybeContentSize; - final Size? maybeViewportSize; - final EdgeInsets? maybeViewportInsets; - - /// The current extent of the sheet. - double get pixels { - assert(_debugAssertHasProperty('pixels', maybePixels)); - return maybePixels!; - } - - /// The minimum extent of the sheet. - double get minPixels { - assert(_debugAssertHasProperty('minPixels', maybeMinPixels)); - return maybeMinPixels!; - } - - /// The maximum extent of the sheet. - double get maxPixels { - assert(_debugAssertHasProperty('maxPixels', maybeMaxPixels)); - return maybeMaxPixels!; - } - - /// The size of the sheet's content. - Size get contentSize { - assert(_debugAssertHasProperty('contentSize', maybeContentSize)); - return maybeContentSize!; - } - - /// The size of the viewport that hosts the sheet. - Size get viewportSize { - assert(_debugAssertHasProperty('viewportSize', maybeViewportSize)); - return maybeViewportSize!; - } - - EdgeInsets get viewportInsets { - assert(_debugAssertHasProperty('viewportInsets', maybeViewportInsets)); - return maybeViewportInsets!; - } - - /// The visible height of the sheet measured from the bottom of the viewport. - /// - /// If the on-screen keyboard is visible, this value is the sum of - /// [pixels] and the keyboard's height. Otherwise, it is equal to [pixels]. - double get viewPixels => pixels + viewportInsets.bottom; - double? get maybeViewPixels => hasDimensions ? viewPixels : null; - - /// The minimum visible height of the sheet measured from the bottom - /// of the viewport. - double get minViewPixels => minPixels + viewportInsets.bottom; - double? get maybeMinViewPixels => hasDimensions ? minViewPixels : null; - - /// The maximum visible height of the sheet measured from the bottom - /// of the viewport. - double get maxViewPixels => maxPixels + viewportInsets.bottom; - double? get maybeMaxViewPixels => hasDimensions ? maxViewPixels : null; - - /// Whether the all metrics are available. - /// - /// Returns true if all of [pixels], [minPixels], [maxPixels], - /// [contentSize], [viewportInsets], and [viewportSize] are not null. - bool get hasDimensions => - maybePixels != null && - maybeMinPixels != null && - maybeMaxPixels != null && - maybeContentSize != null && - maybeViewportSize != null && - maybeViewportInsets != null; - - /// Whether the sheet is within the range of [minPixels] and [maxPixels] - /// (inclusive of both bounds). - bool get isPixelsInBounds => - hasDimensions && pixels.isInBounds(minPixels, maxPixels); - - /// Whether the sheet is outside the range of [minPixels] and [maxPixels]. - bool get isPixelsOutOfBounds => !isPixelsInBounds; - - bool _debugAssertHasProperty(String name, Object? value) { - assert(() { - if (value == null) { - throw FlutterError( - 'SheetMetrics.$name cannot be accessed before the value is set. ' - 'Consider using the corresponding SheetMetrics.maybe* getter ' - 'to handle the case when the value is null. SheetMetrics.hasPixels ' - 'is also useful to check if all the metrics values are set ' - 'before accessing them.', - ); - } - return true; - }()); - return true; - } - - /// Creates a copy of this object with the given fields replaced. - SheetMetrics copyWith({ - SheetStatus? status, - double? pixels, - double? minPixels, - double? maxPixels, - Size? contentSize, - Size? viewportSize, - EdgeInsets? viewportInsets, - }) { - return SheetMetrics( - pixels: pixels ?? maybePixels, - minPixels: minPixels ?? maybeMinPixels, - maxPixels: maxPixels ?? maybeMaxPixels, - contentSize: contentSize ?? maybeContentSize, - viewportSize: viewportSize ?? maybeViewportSize, - viewportInsets: viewportInsets ?? maybeViewportInsets, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SheetMetrics && - runtimeType == other.runtimeType && - maybePixels == other.pixels && - maybeMinPixels == other.minPixels && - maybeMaxPixels == other.maxPixels && - maybeContentSize == other.contentSize && - maybeViewportSize == other.viewportSize && - maybeViewportInsets == other.viewportInsets); - - @override - int get hashCode => Object.hash( - runtimeType, - maybePixels, - maybeMinPixels, - maybeMaxPixels, - maybeContentSize, - maybeViewportSize, - maybeViewportInsets, - ); - - @override - String toString() => ( - hasPixels: hasDimensions, - pixels: maybePixels, - minPixels: maybeMinPixels, - maxPixels: maybeMaxPixels, - viewPixels: maybeViewPixels, - minViewPixels: maybeMinViewPixels, - maxViewPixels: maybeMaxViewPixels, - contentSize: maybeContentSize, - viewportSize: maybeViewportSize, - viewportInsets: maybeViewportInsets, - ).toString(); -} - -/// An interface that provides the necessary context to a [SheetExtent]. -/// -/// Typically, [State]s that host a [SheetExtent] will implement this interface. -@internal -abstract class SheetContext { - TickerProvider get vsync; - BuildContext? get notificationContext; -} - -@internal -@optionalTypeArgs -class SheetExtentScopeKey - extends LabeledGlobalKey<_SheetExtentScopeState> { - SheetExtentScopeKey({String? debugLabel}) : super(debugLabel); - - final List _onCreatedListeners = []; - - T? get maybeCurrentExtent => - switch (currentState?._extent) { final T extent => extent, _ => null }; - - T get currentExtent => maybeCurrentExtent!; - - void addOnCreatedListener(VoidCallback listener) { - _onCreatedListeners.add(listener); - // Immediately notify the listener if the extent is already created. - if (maybeCurrentExtent != null) { - listener(); - } - } - - void removeOnCreatedListener(VoidCallback listener) { - _onCreatedListeners.remove(listener); - } - - void removeAllOnCreatedListeners() { - _onCreatedListeners.clear(); - } - - void _notifySheetExtentCreation() { - for (final listener in _onCreatedListeners) { - listener(); - } - } -} - -@internal -@optionalTypeArgs -abstract class SheetExtentFactory> { - const SheetExtentFactory(); - - @factory - E createSheetExtent({required SheetContext context, required C config}); -} - -/// A widget that creates a [SheetExtent], manages its lifecycle, -/// and exposes it to the descendant widgets. -@internal -@optionalTypeArgs -class SheetExtentScope> - extends StatefulWidget { - /// Creates a widget that hosts a [SheetExtent]. - const SheetExtentScope({ - super.key, - required this.controller, - required this.config, - required this.factory, - this.isPrimary = true, - required this.child, - }); - - /// The [SheetController] that will be attached to the created [SheetExtent]. - final SheetController controller; - - final C config; - - /// The factory that creates the [SheetExtent]. - final SheetExtentFactory factory; - - final bool isPrimary; - - /// The widget below this widget in the tree. - final Widget child; - - @override - State createState() => _SheetExtentScopeState(); - - /// Retrieves a [SheetExtent] from the closest [SheetExtentScope] - /// that encloses the given context, if any. - /// - /// Use of this method will cause the given context to rebuild any time - /// that the [config] property of the ancestor [SheetExtentScope] changes. - // TODO: Add 'useRoot' option. - static E? maybeOf(BuildContext context) { - final inherited = context - .dependOnInheritedWidgetOfExactType<_InheritedSheetExtentScope>() - ?.extent; - - return inherited is E ? inherited : null; - } - - /// Retrieves a [SheetExtent] from the closest [SheetExtentScope] - /// that encloses the given context. - /// - /// Use of this method will cause the given context to rebuild any time - /// that the [config] property of the ancestor [SheetExtentScope] changes. - static E of(BuildContext context) { - final extent = maybeOf(context); - - assert(() { - if (extent == null) { - throw FlutterError( - 'No $SheetExtentScope ancestor for $E could be found starting ' - 'from the context that was passed to $SheetExtentScope.of(). ' - 'The context used was:\n' - '$context', - ); - } - return true; - }()); - - return extent!; - } -} - -class _SheetExtentScopeState> extends State> - with TickerProviderStateMixin - implements SheetContext { - late E _extent; - - @override - TickerProvider get vsync => this; - - @override - BuildContext? get notificationContext => mounted ? context : null; - - SheetExtentScopeKey? get _scopeKey => switch (widget.key) { - final SheetExtentScopeKey key => key, - _ => null, - }; - - @override - void initState() { - super.initState(); - _extent = _createExtent(); - _scopeKey?._notifySheetExtentCreation(); - } - - @override - void dispose() { - _discard(_extent); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _invalidateExtentOwnership(); - } - - @override - void didUpdateWidget(SheetExtentScope oldWidget) { - super.didUpdateWidget(oldWidget); - final oldExtent = _extent; - if (widget.factory != oldWidget.factory) { - _extent = _createExtent()..takeOver(oldExtent); - _scopeKey?._notifySheetExtentCreation(); - _discard(oldExtent); - } else if (widget.config != oldWidget.config) { - _extent.applyNewConfig(widget.config); - } - _invalidateExtentOwnership(); - } - - E _createExtent() { - return widget.factory.createSheetExtent( - context: this, - config: widget.config, - ); - } - - void _discard(E extent) { - widget.controller.detach(extent); - extent.dispose(); - } - - void _invalidateExtentOwnership() { - assert(_debugAssertExtentOwnership()); - - if (widget.isPrimary) { - widget.controller.attach(_extent); - } else { - widget.controller.detach(_extent); - } - } - - bool _debugAssertExtentOwnership() { - assert( - () { - final parentScope = context - .dependOnInheritedWidgetOfExactType<_InheritedSheetExtentScope>(); - if (!widget.isPrimary || - parentScope == null || - !parentScope.isPrimary) { - return true; - } - - throw FlutterError( - 'Nesting SheetExtentScope widgets that are marked as primary ' - 'is not allowed. Typically, this error occurs when you try to nest ' - 'sheet widgets such as DraggableSheet or ScrollableSheet.', - ); - }(), - ); - - return true; - } - - @override - Widget build(BuildContext context) { - return _InheritedSheetExtentScope( - extent: _extent, - isPrimary: widget.isPrimary, - child: widget.child, - ); - } -} - -class _InheritedSheetExtentScope extends InheritedWidget { - const _InheritedSheetExtentScope({ - required this.extent, - required this.isPrimary, - required super.child, - }); - - final SheetExtent extent; - final bool isPrimary; - - @override - bool updateShouldNotify(_InheritedSheetExtentScope oldWidget) => - extent != oldWidget.extent || isPrimary != oldWidget.isPrimary; -} diff --git a/package/lib/src/foundation/sheet_gesture_tamperer.dart b/package/lib/src/foundation/sheet_gesture_tamperer.dart deleted file mode 100644 index f46dc743..00000000 --- a/package/lib/src/foundation/sheet_gesture_tamperer.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import 'sheet_drag.dart'; - -// TODO: Expose this as a public API. -@internal -class TamperSheetGesture extends StatefulWidget { - const TamperSheetGesture({ - super.key, - required this.tamperer, - required this.child, - }); - - final SheetGestureTamperer tamperer; - final Widget child; - - @override - State createState() => _TamperSheetGestureState(); - - static SheetGestureTamperer? maybeOf(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_TamperSheetGestureScope>() - ?.tamperer; - } -} - -class _TamperSheetGestureState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - widget.tamperer.updateParent(TamperSheetGesture.maybeOf(context)); - } - - @override - void didUpdateWidget(TamperSheetGesture oldWidget) { - super.didUpdateWidget(oldWidget); - oldWidget.tamperer.updateParent(null); - widget.tamperer.updateParent(TamperSheetGesture.maybeOf(context)); - } - - @override - void dispose() { - widget.tamperer.updateParent(null); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _TamperSheetGestureScope( - tamperer: widget.tamperer, - child: widget.child, - ); - } -} - -class _TamperSheetGestureScope extends InheritedWidget { - const _TamperSheetGestureScope({ - required this.tamperer, - required super.child, - }); - - final SheetGestureTamperer tamperer; - - @override - bool updateShouldNotify(_TamperSheetGestureScope oldWidget) => - oldWidget.tamperer != tamperer; -} - -// TODO: Expose this as a public API. -@internal -mixin SheetGestureTamperer { - SheetGestureTamperer? _parent; - - @mustCallSuper - void updateParent(SheetGestureTamperer? parent) { - _parent = parent; - } - - @useResult - @mustCallSuper - SheetDragStartDetails tamperWithDragStart(SheetDragStartDetails details) { - return _parent?.tamperWithDragStart(details) ?? details; - } - - @useResult - @mustCallSuper - SheetDragUpdateDetails tamperWithDragUpdate( - SheetDragUpdateDetails details, - Offset minPotentialDeltaConsumption, - ) { - return switch (_parent) { - null => details, - final parent => parent.tamperWithDragUpdate( - details, - minPotentialDeltaConsumption, - ), - }; - } - - @useResult - @mustCallSuper - SheetDragEndDetails tamperWithDragEnd(SheetDragEndDetails details) { - return _parent?.tamperWithDragEnd(details) ?? details; - } -} diff --git a/package/lib/src/foundation/sheet_physics.dart b/package/lib/src/foundation/sheet_physics.dart deleted file mode 100644 index 977c8194..00000000 --- a/package/lib/src/foundation/sheet_physics.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'dart:io'; -import 'dart:math'; -import 'dart:ui'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; - -import '../internal/double_utils.dart'; -import 'sheet_extent.dart'; - -/// The default [SpringDescription] used by [SheetPhysics] subclasses. -/// -/// This spring has the same configuration as the resulting spring -/// from the [SpringDescription.withDampingRatio] constructor with -/// a ratio of `1.1`, a mass of `0.5`, and a stiffness of `100.0`. -const kDefaultSheetSpring = SpringDescription( - mass: 0.5, - stiffness: 100.0, - // Use a pre-calculated value to define the spring as a const variable. - // See the implementation of withDampingRatio() for the formula. - damping: 15.5563491861, // 1.1 * 2.0 * sqrt(0.5 * 100.0) -); - -const _kMinSettlingDuration = Duration(milliseconds: 160); -const _kDefaultSettlingSpeed = 600.0; // logical pixels per second - -/// The default [SheetPhysics] used by sheet widgets. -const kDefaultSheetPhysics = - StretchingSheetPhysics(parent: SnappingSheetPhysics()); - -// TODO: Implement `equals` and `hashCode` for SheetPhysics classes. -abstract class SheetPhysics { - const SheetPhysics({this.parent}); - - final SheetPhysics? parent; - - /// The minimum amount of pixel distance drags must move by to start motion - /// the first time or after each time the drag motion stopped. - /// - /// If null, no minimum threshold is enforced. - double? get dragStartDistanceMotionThreshold { - return Platform.isIOS - ? const BouncingScrollPhysics().dragStartDistanceMotionThreshold - : null; - } - - /// Create a copy of this object appending the [ancestor] to - /// the physics chain, much like [ScrollPhysics.applyTo]. - /// - /// Can be used to dynamically create an inheritance relationship - /// between [SheetPhysics] objects. For example, [SheetPhysics] `x` - /// and `y` in the following code will have the same behavior. - /// ```dart - /// final x = FooSheetPhysics().applyTo(BarSheetPhysics()); - /// final y = FooSheetPhysics(parent: BarSheetPhysics()); - /// ``` - SheetPhysics applyTo(SheetPhysics ancestor) { - return copyWith(parent: parent?.applyTo(ancestor) ?? ancestor); - } - - /// Create a copy of this object with the given fields replaced - /// by the new values. - SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}); - - double computeOverflow(double offset, SheetMetrics metrics); - - double applyPhysicsToOffset(double offset, SheetMetrics metrics); - - Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics); - - Simulation? createSettlingSimulation(SheetMetrics metrics); -} - -mixin SheetPhysicsMixin on SheetPhysics { - SpringDescription get spring => kDefaultSheetSpring; - - @override - double computeOverflow(double offset, SheetMetrics metrics) { - if (parent case final parent?) { - return parent.computeOverflow(offset, metrics); - } - - final newPixels = metrics.pixels + offset; - if (newPixels > metrics.maxPixels) { - return min(newPixels - metrics.maxPixels, offset); - } else if (newPixels < metrics.minPixels) { - return max(newPixels - metrics.minPixels, offset); - } else { - return 0; - } - } - - @override - double applyPhysicsToOffset(double offset, SheetMetrics metrics) { - if (parent case final parent?) { - return parent.applyPhysicsToOffset(offset, metrics); - } else if (offset > 0 && metrics.pixels < metrics.maxPixels) { - // Prevent the pixels from going beyond the maximum value. - return min(metrics.maxPixels, metrics.pixels + offset) - metrics.pixels; - } else if (offset < 0 && metrics.pixels > metrics.minPixels) { - // Prevent the pixels from going beyond the minimum value. - return max(metrics.minPixels, metrics.pixels + offset) - metrics.pixels; - } else { - return 0; - } - } - - @override - Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { - if (parent case final parent?) { - return parent.createBallisticSimulation(velocity, metrics); - } else if (metrics.isPixelsInBounds) { - return null; - } - - final destination = - metrics.pixels.nearest(metrics.minPixels, metrics.maxPixels); - final direction = (destination - metrics.pixels).sign; - - return ScrollSpringSimulation( - spring, - metrics.pixels, - destination, - // The simulation velocity is intentionally set to 0 if the velocity is - // is in the opposite direction of the destination, as flinging up an - // over-dragged sheet or flinging down an under-dragged sheet tends to - // cause unstable motion. - velocity.sign == direction ? velocity : 0.0, - ); - } - - @override - Simulation? createSettlingSimulation(SheetMetrics metrics) { - if (parent case final parent?) { - return parent.createSettlingSimulation(metrics); - } else if (metrics.isPixelsInBounds) { - return null; - } - final settleTo = - metrics.pixels.nearest(metrics.minPixels, metrics.maxPixels); - - return InterpolationSimulation( - start: metrics.pixels, - end: settleTo, - curve: Curves.easeInOut, - durationInSeconds: max( - (metrics.pixels - settleTo).abs() / _kDefaultSettlingSpeed, - _kMinSettlingDuration.inMicroseconds / Duration.microsecondsPerSecond, - ), - ); - } -} - -/// A [Simulation] that interpolates between two values over a given duration. -class InterpolationSimulation extends Simulation { - /// Creates a [Simulation] that interpolates between two values - /// over a given duration. - /// - /// Make sure that [start] and [end] are not equal, and the - /// [durationInSeconds] must be greater than 0. - InterpolationSimulation({ - required this.start, - required this.end, - required this.curve, - required this.durationInSeconds, - }) : assert(start != end), - assert(durationInSeconds > 0); - - /// The start value of the interpolation. - final double start; - - /// The end value of the interpolation. - final double end; - - /// The curve to use for the interpolation. - final Curve curve; - - /// The duration of the interpolation in seconds. - late final double durationInSeconds; - - @override - double dx(double time) { - final epsilon = tolerance.time; - return (x(time + epsilon) - x(time - epsilon)) / (2 * epsilon); - } - - @override - double x(double time) { - final t = curve.transform((time / durationInSeconds).clamp(0, 1)); - return lerpDouble(start, end, t)!; - } - - @override - bool isDone(double time) { - return x(time).isApprox(end); - } -} - -abstract interface class SnappingSheetBehavior { - double? findSnapPixels(double velocity, SheetMetrics metrics); -} - -mixin _SnapToNearestMixin implements SnappingSheetBehavior { - /// The lowest speed (in logical pixels per second) - /// at which a gesture is considered to be a fling. - double get minFlingSpeed; - - @protected - (double, double) _getSnapBoundsContains(SheetMetrics metrics); - - @override - double? findSnapPixels(double velocity, SheetMetrics metrics) { - assert(minFlingSpeed >= 0); - - if (metrics.pixels.isOutOfBounds(metrics.minPixels, metrics.maxPixels)) { - return null; - } - - final (nearestSmaller, nearestGreater) = _getSnapBoundsContains(metrics); - if (velocity.abs() < minFlingSpeed) { - return metrics.pixels.nearest(nearestSmaller, nearestGreater); - } else if (velocity < 0) { - return nearestSmaller; - } else { - return nearestGreater; - } - } -} - -/// A [SnappingSheetBehavior] that snaps to either [SheetMetrics.minPixels] -/// or [SheetMetrics.maxPixels] based on the current sheet position and -/// the gesture velocity. -/// -/// If the absolute value of the gesture velocity is less than -/// [minFlingSpeed], the sheet will snap to the nearest of -/// [SheetMetrics.minPixels] and [SheetMetrics.maxPixels]. -/// Otherwise, the gesture is considered to be a fling, and the sheet will snap -/// towards the direction of the fling. For example, if the sheet is flung up, -/// it will snap to [SheetMetrics.maxPixels]. -/// -/// Using this behavior is functionally identical to using [SnapToNearest] -/// with the snap positions of [SheetExtentConfig.minExtent] and -/// [SheetExtentConfig.maxExtent], but more simplified and efficient. -class SnapToNearestEdge with _SnapToNearestMixin { - /// Creates a [SnappingSheetBehavior] that snaps to either - /// [SheetMetrics.minPixels] or [SheetMetrics.maxPixels]. - /// - /// The [minFlingSpeed] defaults to [kMinFlingVelocity], - /// and must be non-negative. - const SnapToNearestEdge({ - this.minFlingSpeed = kMinFlingVelocity, - }) : assert(minFlingSpeed >= 0); - - @override - final double minFlingSpeed; - - @override - (double, double) _getSnapBoundsContains(SheetMetrics metrics) { - assert(metrics.pixels.isInBounds(metrics.minPixels, metrics.maxPixels)); - return (metrics.minPixels, metrics.maxPixels); - } -} - -class SnapToNearest with _SnapToNearestMixin { - SnapToNearest({ - required this.snapTo, - this.minFlingSpeed = kMinFlingVelocity, - }) : assert(snapTo.isNotEmpty), - assert(minFlingSpeed >= 0); - - final List snapTo; - - @override - final double minFlingSpeed; - - /// Cached results of [Extent.resolve] for each snap position in [snapTo]. - /// - /// Always call [_ensureCacheIsValid] before accessing this list - /// to ensure that the cache is up-to-date and sorted in ascending order. - List _snapTo = const []; - Size? _cachedContentSize; - - void _ensureCacheIsValid(SheetMetrics metrics) { - if (_cachedContentSize != metrics.contentSize) { - _cachedContentSize = metrics.contentSize; - _snapTo = snapTo - .map((e) => e.resolve(metrics.contentSize)) - .toList(growable: false) - ..sort(); - - assert( - _snapTo.first.isGreaterThanOrApprox(metrics.minPixels) && - _snapTo.last.isLessThanOrApprox(metrics.maxPixels), - 'The snap positions must be within the range of ' - "'SheetMetrics.minPixels' and 'SheetMetrics.maxPixels'.", - ); - } - } - - @override - (double, double) _getSnapBoundsContains(SheetMetrics metrics) { - _ensureCacheIsValid(metrics); - if (_snapTo.length == 1) { - return (_snapTo.first, _snapTo.first); - } - - var nearestSmaller = _snapTo[0]; - var nearestGreater = _snapTo[1]; - for (var index = 0; index < _snapTo.length - 1; index++) { - if (_snapTo[index].isLessThan(metrics.pixels)) { - nearestSmaller = _snapTo[index]; - nearestGreater = _snapTo[index + 1]; - } else { - break; - } - } - - return (nearestSmaller, nearestGreater); - } -} - -class SnappingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { - const SnappingSheetPhysics({ - super.parent, - this.spring = kDefaultSheetSpring, - this.snappingBehavior = const SnapToNearestEdge(), - }); - - final SnappingSheetBehavior snappingBehavior; - - @override - final SpringDescription spring; - - @override - SheetPhysics copyWith({ - SheetPhysics? parent, - SpringDescription? spring, - SnappingSheetBehavior? snappingBehavior, - }) { - return SnappingSheetPhysics( - parent: parent ?? this.parent, - spring: spring ?? this.spring, - snappingBehavior: snappingBehavior ?? this.snappingBehavior, - ); - } - - @override - Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { - final snapPixels = snappingBehavior.findSnapPixels(velocity, metrics); - if (snapPixels != null && !metrics.pixels.isApprox(snapPixels)) { - return ScrollSpringSimulation( - spring, - metrics.pixels, - snapPixels, - velocity, - ); - } else { - return super.createBallisticSimulation(velocity, metrics); - } - } -} - -class ClampingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { - const ClampingSheetPhysics({ - super.parent, - this.spring = kDefaultSheetSpring, - }); - - @override - final SpringDescription spring; - - @override - SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}) { - return ClampingSheetPhysics( - parent: parent ?? this.parent, - spring: spring ?? this.spring, - ); - } -} - -class StretchingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { - const StretchingSheetPhysics({ - super.parent, - this.stretchingRange = const Extent.proportional(0.12), - this.frictionCurve = Curves.easeOutSine, - this.spring = kDefaultSheetSpring, - }); - - final Extent stretchingRange; - final Curve frictionCurve; - - @override - final SpringDescription spring; - - @override - SheetPhysics copyWith({ - SheetPhysics? parent, - SpringDescription? spring, - Extent? stretchingRange, - Curve? frictionCurve, - }) { - return StretchingSheetPhysics( - parent: parent ?? this.parent, - spring: spring ?? this.spring, - stretchingRange: stretchingRange ?? this.stretchingRange, - frictionCurve: frictionCurve ?? this.frictionCurve, - ); - } - - @override - double computeOverflow(double offset, SheetMetrics metrics) { - final stretchingRange = this.stretchingRange.resolve(metrics.contentSize); - - if (stretchingRange != 0) { - return 0; - } - - return super.computeOverflow(offset, metrics); - } - - @override - double applyPhysicsToOffset(double offset, SheetMetrics metrics) { - final currentPixels = metrics.pixels; - final minPixels = metrics.minPixels; - final maxPixels = metrics.maxPixels; - - if (currentPixels.isInBounds(minPixels, maxPixels) || - (currentPixels > maxPixels && offset < 0) || - (currentPixels < minPixels && offset > 0)) { - // The friction is not applied if the current 'pixels' is within the range - // or the motion direction is towards the range. - return offset; - } - - final stretchingRange = this.stretchingRange.resolve(metrics.contentSize); - - if (stretchingRange.isApprox(0)) { - return 0; - } - - // We divide the delta into smaller fragments - // and apply friction to each fragment in sequence. - // This ensures that the friction is not too small - // if the delta is too large relative to the overflowing pixels, - // preventing the sheet from slipping too far. - const fragmentSize = 18.0; - var newPixels = currentPixels; - var consumedOffset = 0.0; - while (consumedOffset.abs() < offset.abs()) { - final fragment = (offset - consumedOffset).clampAbs(fragmentSize); - final overflowPastStart = max(minPixels - currentPixels, 0.0); - final overflowPastEnd = max(currentPixels - maxPixels, 0.0); - final overflowPast = max(overflowPastStart, overflowPastEnd); - final overflowFraction = (overflowPast / stretchingRange).clampAbs(1); - final frictionFactor = frictionCurve.transform(overflowFraction); - - newPixels += fragment * (1.0 - frictionFactor); - consumedOffset += fragment; - } - - return newPixels - currentPixels; - } -} diff --git a/package/lib/src/internal/double_utils.dart b/package/lib/src/internal/double_utils.dart deleted file mode 100644 index 7763246c..00000000 --- a/package/lib/src/internal/double_utils.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/physics.dart'; - -extension DoubleUtils on double { - bool isApprox(double value) => - nearEqual(this, value, Tolerance.defaultTolerance.distance); - - bool isLessThan(double value) => this < value && !isApprox(value); - - bool isGreaterThan(double value) => this > value && !isApprox(value); - - bool isLessThanOrApprox(double value) => isLessThan(value) || isApprox(value); - - bool isGreaterThanOrApprox(double value) => - isGreaterThan(value) || isApprox(value); - - bool isOutOfBounds(double min, double max) => - isLessThan(min) || isGreaterThan(max); - - bool isInBounds(double min, double max) => !isOutOfBounds(min, max); - - double clampAbs(double norm) => min(max(-norm, this), norm); - - double nearest(double a, double b) => - (a - this).abs() < (b - this).abs() ? a : b; -} - -double inverseLerp(double min, double max, double value) { - return min == max ? 1.0 : (value - min) / (max - min); -} diff --git a/package/lib/src/navigation/navigation_route.dart b/package/lib/src/navigation/navigation_route.dart deleted file mode 100644 index ffceb414..00000000 --- a/package/lib/src/navigation/navigation_route.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../foundation/sheet_controller.dart'; -import '../foundation/sheet_extent.dart'; -import '../foundation/sheet_viewport.dart'; -import 'navigation_sheet.dart'; -import 'navigation_sheet_extent.dart'; -import 'navigation_sheet_viewport.dart'; - -abstract class NavigationSheetRoute extends PageRoute { - NavigationSheetRoute({super.settings}); - - late NavigationSheetExtent _globalExtent; - - @override - void install() { - super.install(); - assert(_debugAssertDependencies()); - - _globalExtent = SheetExtentScope.of(navigator!.context); - _globalExtent.createLocalExtentScopeKey(this, debugLabel); - } - - @override - void changedExternalState() { - super.changedExternalState(); - // Keep the reference to the global extent up-to-date since we need - // to call disposeLocalExtentScopeKey() in dispose(). - _globalExtent = SheetExtentScope.of(navigator!.context); - } - - @override - void dispose() { - _globalExtent.disposeLocalExtentScopeKey(this); - super.dispose(); - } - - bool _debugAssertDependencies() { - assert( - () { - final globalExtent = SheetExtentScope.maybeOf(navigator!.context); - if (globalExtent is NavigationSheetExtent) { - return true; - } - throw FlutterError( - 'A $SheetExtentScope that hosts a $NavigationSheetExtent ' - 'is not found in the given context. This is likely because ' - 'this $NavigationSheetRoute is not a route of the navigator ' - 'enclosed by a $NavigationSheet.', - ); - }(), - ); - return true; - } - - @override - Color? get barrierColor => null; - - @override - String? get barrierLabel => null; - - RouteTransitionsBuilder? get transitionsBuilder; - - @override - Widget buildTransitions( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - final builder = transitionsBuilder ?? _buildDefaultTransitions; - return builder(context, animation, secondaryAnimation, child); - } - - Widget _buildDefaultTransitions( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - final theme = Theme.of(context).pageTransitionsTheme; - final platformAdaptiveTransitions = theme.buildTransitions( - this, context, animation, secondaryAnimation, child); - - final fadeInTween = TweenSequence([ - TweenSequenceItem(tween: ConstantTween(0.0), weight: 1), - TweenSequenceItem( - tween: Tween(begin: 0.0, end: 1.0).chain( - CurveTween(curve: Curves.easeIn), - ), - weight: 1, - ), - ]); - - final fadeOutTween = TweenSequence([ - TweenSequenceItem( - tween: Tween(begin: 1.0, end: 0.0).chain( - CurveTween(curve: Curves.easeIn), - ), - weight: 1, - ), - TweenSequenceItem(tween: ConstantTween(0.0), weight: 1), - ]); - - return FadeTransition( - opacity: animation.drive(fadeInTween), - child: FadeTransition( - opacity: secondaryAnimation.drive(fadeOutTween), - child: platformAdaptiveTransitions, - ), - ); - } -} - -class NavigationSheetRouteContent extends StatelessWidget { - const NavigationSheetRouteContent({ - super.key, - required this.factory, - required this.config, - required this.child, - }); - - final SheetExtentFactory factory; - final SheetExtentConfig config; - final Widget child; - - @override - Widget build(BuildContext context) { - assert(_debugAssertDependencies(context)); - final parentRoute = ModalRoute.of(context)!; - final globalExtent = SheetExtentScope.of(context); - final localScopeKey = globalExtent.getLocalExtentScopeKey(parentRoute); - - return SheetExtentScope( - key: localScopeKey, - isPrimary: false, - factory: factory, - config: config, - controller: SheetControllerScope.of(context), - child: NavigationSheetRouteViewport( - child: SheetContentViewport(child: child), - ), - ); - } - - bool _debugAssertDependencies(BuildContext context) { - assert( - () { - final parentRoute = ModalRoute.of(context); - if (parentRoute is NavigationSheetRoute) { - return true; - } - throw FlutterError( - 'The $NavigationSheetRouteContent must be the content of ' - 'a $NavigationSheetRoute, but the result of ModalRoute.of(context) ' - 'is ${parentRoute?.runtimeType}.', - ); - }(), - ); - assert( - () { - final globalExtent = SheetExtentScope.maybeOf(context); - if (globalExtent is NavigationSheetExtent) { - return true; - } - throw FlutterError( - 'A $SheetExtentScope that hosts a $NavigationSheetExtent ' - 'is not found in the given context. This is likely because ' - 'this $NavigationSheetRouteContent is not the content of a ' - '$NavigationSheetRoute.', - ); - }(), - ); - return true; - } -} diff --git a/package/lib/src/navigation/navigation_sheet.dart b/package/lib/src/navigation/navigation_sheet.dart deleted file mode 100644 index 44cdb87e..00000000 --- a/package/lib/src/navigation/navigation_sheet.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import '../foundation/keyboard_dismissible.dart'; -import '../foundation/sheet_controller.dart'; -import '../foundation/sheet_extent.dart'; -import '../foundation/sheet_gesture_tamperer.dart'; -import '../foundation/sheet_physics.dart'; -import '../foundation/sheet_theme.dart'; -import '../foundation/sheet_viewport.dart'; -import '../internal/transition_observer.dart'; -import 'navigation_sheet_extent.dart'; -import 'navigation_sheet_viewport.dart'; - -typedef NavigationSheetTransitionObserver = TransitionObserver; - -class NavigationSheet extends StatefulWidget with TransitionAwareWidgetMixin { - const NavigationSheet({ - super.key, - required this.transitionObserver, - this.keyboardDismissBehavior, - this.controller, - required this.child, - }); - - @override - final NavigationSheetTransitionObserver transitionObserver; - - final SheetKeyboardDismissBehavior? keyboardDismissBehavior; - final SheetController? controller; - final Widget child; - - @override - State createState() => _NavigationSheetState(); -} - -class _NavigationSheetState extends State - with TransitionAwareStateMixin - implements SheetExtentFactory { - final _scopeKey = SheetExtentScopeKey( - debugLabel: kDebugMode ? 'NavigationSheet' : null, - ); - - @override - void didChangeTransitionState(Transition? transition) { - _scopeKey.maybeCurrentExtent?.handleRouteTransition(transition); - } - - @factory - @override - NavigationSheetExtent createSheetExtent({ - required SheetContext context, - required SheetExtentConfig config, - }) { - return NavigationSheetExtent( - context: context, - config: config, - ); - } - - @override - Widget build(BuildContext context) { - final theme = SheetTheme.maybeOf(context); - final keyboardDismissBehavior = - widget.keyboardDismissBehavior ?? theme?.keyboardDismissBehavior; - final gestureTamper = TamperSheetGesture.maybeOf(context); - - Widget result = ImplicitSheetControllerScope( - controller: widget.controller, - builder: (context, controller) { - return SheetExtentScope( - key: _scopeKey, - controller: controller, - factory: this, - isPrimary: true, - config: SheetExtentConfig( - minExtent: const Extent.pixels(0), - maxExtent: const Extent.proportional(1), - // TODO: Use more appropriate physics. - physics: const ClampingSheetPhysics(), - gestureTamperer: gestureTamper, - debugLabel: kDebugMode ? 'NavigationSheet' : null, - ), - child: NavigationSheetViewport( - child: SheetContentViewport(child: widget.child), - ), - ); - }, - ); - - if (keyboardDismissBehavior != null) { - result = SheetKeyboardDismissible( - dismissBehavior: keyboardDismissBehavior, - child: result, - ); - } - - return result; - } -} diff --git a/package/lib/src/navigation/navigation_sheet_extent.dart b/package/lib/src/navigation/navigation_sheet_extent.dart deleted file mode 100644 index 90a916b5..00000000 --- a/package/lib/src/navigation/navigation_sheet_extent.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; - -import '../foundation/sheet_extent.dart'; -import '../internal/transition_observer.dart'; -import 'navigation_sheet_activity.dart'; - -@internal -class NavigationSheetExtent extends SheetExtent { - NavigationSheetExtent({ - required super.context, - required super.config, - }); - - final _localExtentScopeKeyRegistry = , SheetExtentScopeKey>{}; - Transition? _lastReportedTransition; - - SheetExtentScopeKey createLocalExtentScopeKey( - Route route, - String? debugLabel, - ) { - assert(!_localExtentScopeKeyRegistry.containsKey(route)); - final key = SheetExtentScopeKey(debugLabel: debugLabel); - _localExtentScopeKeyRegistry[route] = key; - return key; - } - - void disposeLocalExtentScopeKey(Route route) { - assert(_localExtentScopeKeyRegistry.containsKey(route)); - final key = _localExtentScopeKeyRegistry.remove(route); - key!.removeAllOnCreatedListeners(); - } - - SheetExtentScopeKey getLocalExtentScopeKey(Route route) { - assert(_localExtentScopeKeyRegistry.containsKey(route)); - return _localExtentScopeKeyRegistry[route]!; - } - - bool containsLocalExtentScopeKey(Route route) { - return _localExtentScopeKeyRegistry.containsKey(route); - } - - @override - void takeOver(SheetExtent other) { - super.takeOver(other); - if (other is NavigationSheetExtent) { - assert(_localExtentScopeKeyRegistry.isEmpty); - _lastReportedTransition = other._lastReportedTransition; - _localExtentScopeKeyRegistry.addAll(other._localExtentScopeKeyRegistry); - // Prevent the scope keys in `other._localExtentScopeKeyRegistry` from - // being discarded when `other` is disposed. - other._localExtentScopeKeyRegistry.clear(); - assert(_debugAssertActivityTypeConsistency()); - } - } - - @override - void dispose() { - super.dispose(); - for (final scopeKey in _localExtentScopeKeyRegistry.values) { - scopeKey.removeAllOnCreatedListeners(); - } - _localExtentScopeKeyRegistry.clear(); - } - - void handleRouteTransition(Transition? transition) { - _lastReportedTransition = transition; - // TODO: Provide a way to customize animation curves. - switch (transition) { - case NoTransition(:final currentRoute): - beginActivity(ProxySheetActivity(route: currentRoute)); - - case final ForwardTransition tr: - beginActivity(TransitionSheetActivity( - currentRoute: tr.originRoute, - nextRoute: tr.destinationRoute, - animation: tr.animation, - animationCurve: Curves.easeInOutCubic, - )); - - case final BackwardTransition tr: - beginActivity(TransitionSheetActivity( - currentRoute: tr.originRoute, - nextRoute: tr.destinationRoute, - animation: tr.animation, - animationCurve: Curves.easeInOutCubic, - )); - - case final UserGestureTransition tr: - beginActivity(TransitionSheetActivity( - currentRoute: tr.currentRoute, - nextRoute: tr.previousRoute, - animation: tr.animation, - animationCurve: Curves.linear, - )); - - case _: - goIdle(); - } - - assert(_debugAssertActivityTypeConsistency()); - } - - @override - void goIdle() { - switch (_lastReportedTransition) { - case NoTransition(:final currentRoute): - beginActivity(ProxySheetActivity(route: currentRoute)); - case _: - super.goIdle(); - } - } - - @override - void dispatchUpdateNotification() { - // Do not dispatch a notifications if a local extent is active. - if (activity is! NavigationSheetActivity) { - super.dispatchUpdateNotification(); - } - } - - @override - void dispatchDragStartNotification() { - // Do not dispatch a notifications if a local extent is active. - if (activity is! NavigationSheetActivity) { - super.dispatchDragStartNotification(); - } - } - - @override - void dispatchDragEndNotification() { - // Do not dispatch a notifications if a local extent is active. - if (activity is! NavigationSheetActivity) { - super.dispatchDragEndNotification(); - } - } - - @override - void dispatchDragUpdateNotification() { - // Do not dispatch a notifications if a local extent is active. - if (activity is! NavigationSheetActivity) { - super.dispatchDragUpdateNotification(); - } - } - - @override - void dispatchOverflowNotification(double overflow) { - // Do not dispatch a notifications if a local extent is active. - if (activity is! NavigationSheetActivity) { - super.dispatchOverflowNotification(overflow); - } - } - - bool _debugAssertActivityTypeConsistency() { - assert( - () { - switch ((_lastReportedTransition, activity)) { - case (NoTransition(), ProxySheetActivity()): - case (ForwardTransition(), TransitionSheetActivity()): - case (BackwardTransition(), TransitionSheetActivity()): - case (UserGestureTransition(), TransitionSheetActivity()): - case (null, _): - return true; - case _: - return false; - } - }(), - ); - return true; - } -} diff --git a/package/lib/src/scrollable/scrollable_sheet.dart b/package/lib/src/scrollable/scrollable_sheet.dart deleted file mode 100644 index eb6c3413..00000000 --- a/package/lib/src/scrollable/scrollable_sheet.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -import '../foundation/keyboard_dismissible.dart'; -import '../foundation/sheet_controller.dart'; -import '../foundation/sheet_extent.dart'; -import '../foundation/sheet_gesture_tamperer.dart'; -import '../foundation/sheet_physics.dart'; -import '../foundation/sheet_theme.dart'; -import '../foundation/sheet_viewport.dart'; -import 'scrollable_sheet_extent.dart'; -import 'sheet_scrollable.dart'; - -class ScrollableSheet extends StatelessWidget { - const ScrollableSheet({ - super.key, - this.keyboardDismissBehavior, - this.initialExtent = const Extent.proportional(1), - this.minExtent = const Extent.proportional(1), - this.maxExtent = const Extent.proportional(1), - this.physics, - this.controller, - required this.child, - }); - - /// The strategy to dismiss the on-screen keyboard when the sheet is dragged. - final SheetKeyboardDismissBehavior? keyboardDismissBehavior; - - /// {@macro ScrollableSheetExtent.initialExtent} - final Extent initialExtent; - - /// {@macro SheetExtentConfig.minExtent} - final Extent minExtent; - - /// {@macro SheetExtentConfig.maxExtent} - final Extent maxExtent; - - /// {@macro SheetExtentConfig.physics} - final SheetPhysics? physics; - - /// An object that can be used to control and observe the sheet height. - final SheetController? controller; - - /// The content of the sheet. - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = SheetTheme.maybeOf(context); - final keyboardDismissBehavior = - this.keyboardDismissBehavior ?? theme?.keyboardDismissBehavior; - final gestureTamper = TamperSheetGesture.maybeOf(context); - - Widget result = ImplicitSheetControllerScope( - controller: controller, - builder: (context, controller) { - return SheetExtentScope( - controller: controller, - factory: const ScrollableSheetExtentFactory(), - config: ScrollableSheetExtentConfig.withFallbacks( - initialExtent: initialExtent, - minExtent: minExtent, - maxExtent: maxExtent, - physics: physics ?? theme?.physics, - gestureTamperer: gestureTamper, - debugLabel: 'ScrollableSheet', - ), - child: SheetViewport( - child: SheetContentViewport( - child: ScrollableSheetContent(child: child), - )), - ); - }, - ); - - if (keyboardDismissBehavior != null) { - result = SheetKeyboardDismissible( - dismissBehavior: keyboardDismissBehavior, - child: result, - ); - } - - return result; - } -} - -@internal -class ScrollableSheetContent extends StatelessWidget { - const ScrollableSheetContent({ - super.key, - this.debugLabel, - this.keepScrollOffset = true, - this.initialScrollOffset = 0, - required this.child, - }); - - final String? debugLabel; - final bool keepScrollOffset; - final double initialScrollOffset; - final Widget child; - - @override - Widget build(BuildContext context) { - return SheetScrollable( - debugLabel: debugLabel, - keepScrollOffset: keepScrollOffset, - initialScrollOffset: initialScrollOffset, - builder: (context, controller) { - return PrimaryScrollController( - controller: controller, - child: child, - ); - }, - ); - } -} diff --git a/package/pubspec.lock b/package/pubspec.lock new file mode 100644 index 00000000..b81de8ea --- /dev/null +++ b/package/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: transitive + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: "direct main" + description: + name: meta + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + pedantic_mono: + dependency: "direct dev" + description: + name: pedantic_mono + sha256: "3c42858286d72fba37af67f635b8bf4f0a51b1909f97a52d822d8d5cb3a7dbc9" + url: "https://pub.dev" + source: hosted + version: "1.26.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + url: "https://pub.dev" + source: hosted + version: "13.0.0" +sdks: + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/package/test/foundation/physics_test.dart b/package/test/foundation/physics_test.dart deleted file mode 100644 index 3204bc61..00000000 --- a/package/test/foundation/physics_test.dart +++ /dev/null @@ -1,356 +0,0 @@ -// ignore_for_file: lines_longer_than_80_chars -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:smooth_sheets/smooth_sheets.dart'; - -class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics - with SheetPhysicsMixin { - const _SheetPhysicsWithDefaultConfiguration(); - - @override - SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}) { - return const _SheetPhysicsWithDefaultConfiguration(); - } -} - -const _referenceSheetMetrics = SheetMetrics( - minPixels: 0, - maxPixels: 600, - pixels: 600, - contentSize: Size(360, 600), - viewportSize: Size(360, 700), - viewportInsets: EdgeInsets.zero, -); - -final _positionAtTopEdge = - _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.maxPixels); - -final _positionAtBottomEdge = - _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.minPixels); - -final _positionAtMiddle = _referenceSheetMetrics.copyWith( - pixels: (_positionAtTopEdge.pixels + _positionAtBottomEdge.pixels) / 2, -); - -void main() { - group('$SheetPhysics subclasses', () { - test('can create dynamic inheritance relationships', () { - const clamp = ClampingSheetPhysics(); - const stretch = StretchingSheetPhysics(); - const snap = SnappingSheetPhysics(); - - List getChain(SheetPhysics physics) { - return switch (physics.parent) { - null => [physics.runtimeType], - final parent => [physics.runtimeType, ...getChain(parent)], - }; - } - - expect( - getChain(clamp.applyTo(stretch).applyTo(snap)).join(' -> '), - 'ClampingSheetPhysics -> StretchingSheetPhysics -> SnappingSheetPhysics', - ); - - expect( - getChain(snap.applyTo(stretch).applyTo(clamp)).join(' -> '), - 'SnappingSheetPhysics -> StretchingSheetPhysics -> ClampingSheetPhysics', - ); - - expect( - getChain(stretch.applyTo(clamp).applyTo(snap)).join(' -> '), - 'StretchingSheetPhysics -> ClampingSheetPhysics -> SnappingSheetPhysics', - ); - }); - }); - - group('Default configuration of $SheetPhysics', () { - late SheetPhysics physicsUnderTest; - - setUp(() { - physicsUnderTest = const _SheetPhysicsWithDefaultConfiguration(); - }); - - test('does not allow over/under dragging', () { - expect( - physicsUnderTest.computeOverflow(10, _positionAtTopEdge), - moreOrLessEquals(10), - ); - expect( - physicsUnderTest.computeOverflow(-10, _positionAtBottomEdge), - moreOrLessEquals(-10), - ); - }); - - test('does not apply any resistance if position is in bounds', () { - final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels - 10); - final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels + 10); - - expect( - physicsUnderTest.applyPhysicsToOffset(10, _positionAtMiddle), - moreOrLessEquals(10), - ); - expect( - physicsUnderTest.applyPhysicsToOffset(10, positionAtNearTopEdge), - moreOrLessEquals(10), - ); - expect( - physicsUnderTest.applyPhysicsToOffset(-10, positionAtNearBottomEdge), - moreOrLessEquals(-10), - ); - }); - - test('prevents position from going out of bounds', () { - expect( - physicsUnderTest.applyPhysicsToOffset(10, _positionAtTopEdge), - moreOrLessEquals(0), - ); - expect( - physicsUnderTest.applyPhysicsToOffset(-10, _positionAtBottomEdge), - moreOrLessEquals(0), - ); - }); - - test('creates no ballistic simulation if position is in bounds', () { - expect( - physicsUnderTest.createBallisticSimulation(0, _positionAtMiddle), - isNull, - ); - expect( - physicsUnderTest.createBallisticSimulation(0, _positionAtTopEdge), - isNull, - ); - expect( - physicsUnderTest.createBallisticSimulation(0, _positionAtBottomEdge), - isNull, - ); - }); - - test('creates ballistic simulation which ends at the nearest edge', () { - final overDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels + 10, - ); - final underDragPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels - 10, - ); - final overDragSimulation = - physicsUnderTest.createBallisticSimulation(0, overDraggedPosition); - final underDraggedSimulation = - physicsUnderTest.createBallisticSimulation(0, underDragPosition); - - expect(overDragSimulation, isNotNull); - expect( - overDragSimulation!.x(5), // 5s passed - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - expect( - overDragSimulation.dx(5), // 5s passed - moreOrLessEquals(0), - ); - - expect(underDraggedSimulation, isNotNull); - expect( - underDraggedSimulation!.x(5), // 5s passed - moreOrLessEquals(_referenceSheetMetrics.minPixels), - ); - expect( - underDraggedSimulation.dx(5), // 5s passed - moreOrLessEquals(0), - ); - }); - - test('creates no settling simulation if the position is in bounds', () { - expect( - physicsUnderTest.createSettlingSimulation(_positionAtMiddle), - isNull, - ); - expect( - physicsUnderTest.createSettlingSimulation(_positionAtTopEdge), - isNull, - ); - expect( - physicsUnderTest.createSettlingSimulation(_positionAtBottomEdge), - isNull, - ); - }); - - test('creates settling simulation which ends at nearest edge', () { - final moreOverDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels + 200, - ); - final lessOverDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels + 10, - ); - final moreOverDragSimulation = - physicsUnderTest.createSettlingSimulation(moreOverDraggedPosition); - final lessOverDragSimulation = - physicsUnderTest.createSettlingSimulation(lessOverDraggedPosition); - - // The settling simulation runs with the average velocity of 600px/s - // if the starting position is far enough from the edge. - expect(moreOverDragSimulation, isNotNull); - expect( - moreOverDragSimulation!.x(0.170), // 170ms passed - greaterThan(_referenceSheetMetrics.maxPixels), - ); - expect( - moreOverDragSimulation.x(0.334), // 334ms passed (≈ 200px / 600px/s) - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - - // The default behavior ensures that the settling simulation runs for - // at least 160ms even if the starting position is too close to the edge. - expect(lessOverDragSimulation, isNotNull); - expect( - lessOverDragSimulation!.x(0.08), // 80ms passed - greaterThan(_referenceSheetMetrics.maxPixels), - ); - expect( - lessOverDragSimulation.x(0.16), // 160ms passed - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - }); - }); - - group('$SnapToNearestEdge', () { - late SnapToNearestEdge behaviorUnderTest; - - setUp(() { - behaviorUnderTest = const SnapToNearestEdge(minFlingSpeed: 50); - }); - - test('snaps to nearest edge if velocity is small enough', () { - final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels - 50, - ); - final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels + 50, - ); - - expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearTopEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), - ); - }); - - test('is aware of fling gesture direction', () { - expect( - behaviorUnderTest.findSnapPixels(50, _positionAtBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - expect( - behaviorUnderTest.findSnapPixels(-50, _positionAtTopEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), - ); - }); - - test('is disabled if position is out of bounds', () { - final overDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels + 10, - ); - final underDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels - 10, - ); - - expect( - behaviorUnderTest.findSnapPixels(0, overDraggedPosition), - isNull, - ); - expect( - behaviorUnderTest.findSnapPixels(0, underDraggedPosition), - isNull, - ); - }); - }); - group('$SnapToNearest', () { - late SnapToNearest behaviorUnderTest; - - setUp(() { - behaviorUnderTest = SnapToNearest( - minFlingSpeed: 50, - snapTo: [ - Extent.pixels(_positionAtBottomEdge.pixels), - Extent.pixels(_positionAtMiddle.pixels), - Extent.pixels(_positionAtTopEdge.pixels), - ], - ); - }); - - test('snaps to nearest edge if velocity is small enough', () { - final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels - 50, - ); - final positionAtNearMiddle = _referenceSheetMetrics.copyWith( - pixels: _positionAtMiddle.pixels + 50, - ); - final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels + 50, - ); - - expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearTopEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), - ); - expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearMiddle), - moreOrLessEquals(_positionAtMiddle.pixels), - ); - expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), - ); - }); - - test('is aware of fling gesture direction', () { - final positionAtAboveMiddle = _positionAtMiddle.copyWith( - pixels: _positionAtMiddle.pixels + 10, - ); - final positionAtBelowMiddle = _positionAtMiddle.copyWith( - pixels: _positionAtMiddle.pixels - 10, - ); - // Flings up at the bottom edge - expect( - behaviorUnderTest.findSnapPixels(50, _positionAtBottomEdge), - moreOrLessEquals(_positionAtMiddle.pixels), - ); - // Flings up at the slightly above the middle position - expect( - behaviorUnderTest.findSnapPixels(50, positionAtAboveMiddle), - moreOrLessEquals(_positionAtTopEdge.pixels), - ); - // Flings down at the top edge - expect( - behaviorUnderTest.findSnapPixels(-50, _positionAtTopEdge), - moreOrLessEquals(_positionAtMiddle.pixels), - ); - // Flings down at the slightly below the middle position - expect( - behaviorUnderTest.findSnapPixels(-50, positionAtBelowMiddle), - moreOrLessEquals(_positionAtBottomEdge.pixels), - ); - }); - - test('is disabled if position is out of bounds', () { - final overDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.maxPixels + 10, - ); - final underDraggedPosition = _referenceSheetMetrics.copyWith( - pixels: _referenceSheetMetrics.minPixels - 10, - ); - - expect( - behaviorUnderTest.findSnapPixels(0, overDraggedPosition), - isNull, - ); - expect( - behaviorUnderTest.findSnapPixels(0, underDraggedPosition), - isNull, - ); - }); - }); -} diff --git a/package/pubspec.yaml b/pubspec.yaml similarity index 75% rename from package/pubspec.yaml rename to pubspec.yaml index 3fc49f5c..85f8e1dc 100644 --- a/package/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,14 @@ name: smooth_sheets description: Sheet widgets with smooth motion and great flexibility. Also supports nested navigation in both imperative and declarative ways. -version: 0.7.1 +version: 0.10.0 repository: https://github.com/fujidaiti/smooth_sheets screenshots: - description: Practical examples of smooth_sheets. - path: resources/smooth-sheets-thumbnail.png + path: art/smooth-sheets-thumbnail.png +topics: + - ui + - widget + - bottom-sheet environment: sdk: ">=3.2.0 <4.0.0" @@ -17,6 +21,9 @@ dependencies: meta: ^1.9.1 dev_dependencies: + build_runner: ^2.4.9 flutter_test: sdk: flutter + go_router: ^14.2.3 + mockito: ^5.4.4 pedantic_mono: ^1.23.0 diff --git a/resources/cookbook-ai-playlist-generator.mp4 b/resources/cookbook-ai-playlist-generator.mp4 deleted file mode 100644 index c1dcf3b1..00000000 Binary files a/resources/cookbook-ai-playlist-generator.mp4 and /dev/null differ diff --git a/resources/cookbook-airbnb.mp4 b/resources/cookbook-airbnb.mp4 deleted file mode 100644 index 5f24e44a..00000000 Binary files a/resources/cookbook-airbnb.mp4 and /dev/null differ diff --git a/resources/cookbook-declarative-modal-sheet.mp4 b/resources/cookbook-declarative-modal-sheet.mp4 deleted file mode 100644 index 6c4e93d2..00000000 Binary files a/resources/cookbook-declarative-modal-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-declarative-navigation-sheet.mp4 b/resources/cookbook-declarative-navigation-sheet.mp4 deleted file mode 100644 index e85cff6a..00000000 Binary files a/resources/cookbook-declarative-navigation-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-draggable-sheet.mp4 b/resources/cookbook-draggable-sheet.mp4 deleted file mode 100644 index 0b5594ae..00000000 Binary files a/resources/cookbook-draggable-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-imperative-modal-sheet.mp4 b/resources/cookbook-imperative-modal-sheet.mp4 deleted file mode 100644 index 74577d96..00000000 Binary files a/resources/cookbook-imperative-modal-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-imperative-navigation-sheet.mp4 b/resources/cookbook-imperative-navigation-sheet.mp4 deleted file mode 100644 index 474cd81b..00000000 Binary files a/resources/cookbook-imperative-navigation-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-scrollable-sheet.mp4 b/resources/cookbook-scrollable-sheet.mp4 deleted file mode 100644 index 5199d008..00000000 Binary files a/resources/cookbook-scrollable-sheet.mp4 and /dev/null differ diff --git a/resources/cookbook-sheet-content-scaffold.mp4 b/resources/cookbook-sheet-content-scaffold.mp4 deleted file mode 100644 index c0d2983e..00000000 Binary files a/resources/cookbook-sheet-content-scaffold.mp4 and /dev/null differ diff --git a/resources/cookbook-sheet-controller.mp4 b/resources/cookbook-sheet-controller.mp4 deleted file mode 100644 index 6219eda0..00000000 Binary files a/resources/cookbook-sheet-controller.mp4 and /dev/null differ diff --git a/resources/cookbook-sheet-draggable.mp4 b/resources/cookbook-sheet-draggable.mp4 deleted file mode 100644 index ad396215..00000000 Binary files a/resources/cookbook-sheet-draggable.mp4 and /dev/null differ diff --git a/resources/cookbook-sheet-physics.mp4 b/resources/cookbook-sheet-physics.mp4 deleted file mode 100644 index 3d05a822..00000000 Binary files a/resources/cookbook-sheet-physics.mp4 and /dev/null differ diff --git a/scripts/copilot/pattern_checker_session.json b/scripts/copilot/pattern_checker_session.json deleted file mode 100644 index 7a992032..00000000 --- a/scripts/copilot/pattern_checker_session.json +++ /dev/null @@ -1,496 +0,0 @@ -{ - "requesterUsername": "fujidaiti", - "requesterAvatarIconUri": { - "$mid": 1, - "path": "/fujidaiti", - "scheme": "https", - "authority": "avatars.githubusercontent.com" - }, - "responderUsername": "GitHub Copilot", - "welcomeMessage": [ - "Hi **@fujidaiti**, how can I help you?" - ], - "requests": [ - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 1019 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 13, - "endColumn": 120 - }, - "text": "Could you write a shell script which fails if specified words or patterns are found in local files? The inputs are:\n\nA list of patterns of local files (e.g. *.dart), which will be targets of this script.\nA list of disallowed words or patterns (regex), for which this script will fail if at least one of them is found in the target files.\nThis script must exit with 1 (error status) if any disallowed words or patterns are found in the target files, othwerwise, it must exist with 0. Make sure the all of the target files must be processed even if an problem is found within a file in the middle of the entier process.\n\nIn addition, this script must echo human friendly logs for each problem found in the process. Each log must contains the following information:\n\nFile name\nThe line number in which a disallowed word or a pattenrn is found\nA snippet of the single line in which the problem is found\n\nNote that this script must not modify the original files. It just scans them and prints diagnosis found in the process.", - "kind": "text" - } - ], - "text": "Could you write a shell script which fails if specified words or patterns are found in local files? The inputs are:\n\nA list of patterns of local files (e.g. *.dart), which will be targets of this script.\nA list of disallowed words or patterns (regex), for which this script will fail if at least one of them is found in the target files.\nThis script must exit with 1 (error status) if any disallowed words or patterns are found in the target files, othwerwise, it must exist with 0. Make sure the all of the target files must be processed even if an problem is found within a file in the middle of the entier process.\n\nIn addition, this script must echo human friendly logs for each problem found in the process. Each log must contains the following information:\n\nFile name\nThe line number in which a disallowed word or a pattenrn is found\nA snippet of the single line in which the problem is found\n\nNote that this script must not modify the original files. It just scans them and prints diagnosis found in the process." - }, - "response": [ - { - "value": "Here is a shell script that meets your requirements. This script uses `grep` to search for the disallowed patterns in the specified files. If a match is found, it prints the filename, line number, and the matching line. If any matches are found, the script exits with a status of 1.\n\n```bash\n#!/bin/bash\n\n# List of file patterns\nfile_patterns=(\"*.dart\")\n\n# List of disallowed words or patterns\ndisallowed_patterns=(\"pattern1\" \"pattern2\")\n\n# Flag to track if any disallowed pattern is found\nerror_found=0\n\n# Loop over each file pattern\nfor file_pattern in \"${file_patterns[@]}\"; do\n # Find files matching the pattern\n files=$(find . -name \"$file_pattern\")\n\n # Loop over each file\n for file in $files; do\n # Loop over each disallowed pattern\n for pattern in \"${disallowed_patterns[@]}\"; do\n # Search for the pattern in the file\n result=$(grep -n \"$pattern\" \"$file\")\n\n # If a match is found\n if [[ $result ]]; then\n # Set the error flag\n error_found=1\n\n # Print the filename, line number, and matching line\n echo \"Disallowed pattern found in file: $file\"\n echo \"$result\"\n fi\n done\n done\ndone\n\n# If any disallowed pattern was found, exit with status 1\nif [[ $error_found -eq 1 ]]; then\n exit 1\nfi\n\n# If no disallowed pattern was found, exit with status 0\nexit 0\n```\n\nYou can customize the `file_patterns` and `disallowed_patterns` arrays as needed. This script will recursively search for files in the current directory and its subdirectories. If you want to search in a different directory, you can change the `.` in the `find` command to the path of the directory you want to search in.", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I exclude certain directories from the file search in the shell script?", - "title": "How can I exclude certain directories from the file search in the shell script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - }, - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 135 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 1, - "endColumn": 136 - }, - "text": "Nice! Could you modify the script so that we can get the target file patterns and the disallowed patterns from the script's argumenets?", - "kind": "text" - } - ], - "text": "Nice! Could you modify the script so that we can get the target file patterns and the disallowed patterns from the script's argumenets?" - }, - "response": [ - { - "value": "Sure, here's how you can modify the script to accept the target file patterns and disallowed patterns as arguments. The first argument will be the target file pattern, and all subsequent arguments will be treated as disallowed patterns.\n\n```bash\n#!/bin/bash\n\n# File pattern from the first argument\nfile_pattern=$1\n\n# Disallowed patterns from the rest of the arguments\ndisallowed_patterns=${@:2}\n\n# Flag to track if any disallowed pattern is found\nerror_found=0\n\n# Find files matching the pattern\nfiles=$(find . -name \"$file_pattern\")\n\n# Loop over each file\nfor file in $files; do\n # Loop over each disallowed pattern\n for pattern in $disallowed_patterns; do\n # Search for the pattern in the file\n result=$(grep -n \"$pattern\" \"$file\")\n\n # If a match is found\n if [[ $result ]]; then\n # Set the error flag\n error_found=1\n\n # Print the filename, line number, and matching line\n echo \"Disallowed pattern found in file: $file\"\n echo \"$result\"\n fi\n done\ndone\n\n# If any disallowed pattern was found, exit with status 1\nif [[ $error_found -eq 1 ]]; then\n exit 1\nfi\n\n# If no disallowed pattern was found, exit with status 0\nexit 0\n```\n\nYou can run this script with the file pattern as the first argument and the disallowed patterns as the rest of the arguments. For example:\n\n```bash\n./script.sh \"*.dart\" \"pattern1\" \"pattern2\"\n```", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I pass multiple file patterns to the shell script?", - "title": "How can I pass multiple file patterns to the shell script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - }, - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 41 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 1, - "endColumn": 42 - }, - "text": "How can I specify multiple file patterns?", - "kind": "text" - } - ], - "text": "How can I specify multiple file patterns?" - }, - "response": [ - { - "value": "To specify multiple file patterns, you can pass them as separate arguments and modify the script to treat each argument before the disallowed patterns as a file pattern. You'll need to specify a delimiter argument (like `--`) to separate the file patterns from the disallowed patterns.\n\nHere's how you can modify the script:\n\n```bash\n#!/bin/bash\n\n# Arrays to hold file patterns and disallowed patterns\nfile_patterns=()\ndisallowed_patterns=()\n\n# Flag to track if we're still reading file patterns\nreading_patterns=1\n\n# Loop over each argument\nfor arg in \"$@\"; do\n # If we encounter the delimiter, stop reading file patterns\n if [[ $arg == \"--\" ]]; then\n reading_patterns=0\n # If we're still reading file patterns\n elif [[ $reading_patterns -eq 1 ]]; then\n file_patterns+=(\"$arg\")\n # If we're reading disallowed patterns\n else\n disallowed_patterns+=(\"$arg\")\n fi\ndone\n\n# Flag to track if any disallowed pattern is found\nerror_found=0\n\n# Loop over each file pattern\nfor file_pattern in \"${file_patterns[@]}\"; do\n # Find files matching the pattern\n files=$(find . -name \"$file_pattern\")\n\n # Loop over each file\n for file in $files; do\n # Loop over each disallowed pattern\n for pattern in \"${disallowed_patterns[@]}\"; do\n # Search for the pattern in the file\n result=$(grep -n \"$pattern\" \"$file\")\n\n # If a match is found\n if [[ $result ]]; then\n # Set the error flag\n error_found=1\n\n # Print the filename, line number, and matching line\n echo \"Disallowed pattern found in file: $file\"\n echo \"$result\"\n fi\n done\n done\ndone\n\n# If any disallowed pattern was found, exit with status 1\nif [[ $error_found -eq 1 ]]; then\n exit 1\nfi\n\n# If no disallowed pattern was found, exit with status 0\nexit 0\n```\n\nYou can run this script with the file patterns as the first set of arguments, the delimiter `--`, and the disallowed patterns as the rest of the arguments. For example:\n\n```bash\n./script.sh \"*.dart\" \"*.txt\" \"--\" \"pattern1\" \"pattern2\"\n```", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I exclude certain directories from the file search in the shell script?", - "title": "How can I exclude certain directories from the file search in the shell script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - }, - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 77 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 1, - "endColumn": 78 - }, - "text": "Soooooooooo nice, thanks! Lastly, what is the good name of this great script?", - "kind": "text" - } - ], - "text": "Soooooooooo nice, thanks! Lastly, what is the good name of this great script?" - }, - "response": [ - { - "value": "You're welcome! A good name for this script could be `pattern_checker.sh` or `disallowed_pattern_detector.sh`. This name suggests that the script checks for certain patterns in files, which is its main function. Remember to make the script executable with the command `chmod +x pattern_checker.sh` before running it.", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I exclude specific directories from the file search in the script?", - "title": "How can I exclude specific directories from the file search in the script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - }, - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 60 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 1, - "endColumn": 61 - }, - "text": "Thanks. Can you write a small documentation for this script?", - "kind": "text" - } - ], - "text": "Thanks. Can you write a small documentation for this script?" - }, - "response": [ - { - "value": "Sure, here's a brief documentation for the `pattern_checker.sh` script:\n\n---\n\n# Pattern Checker Script\n\nThe `pattern_checker.sh` script is a utility for scanning files for disallowed patterns. It accepts a list of file patterns and a list of disallowed patterns as arguments, and scans all files matching the file patterns for the disallowed patterns.\n\n## Usage\n\n```bash\n./pattern_checker.sh [file patterns] -- [disallowed patterns]\n```\n\n### Parameters:\n\n- `[file patterns]`: A list of file patterns to scan. Each pattern should be quoted and separated by a space. For example, `\"*.dart\" \"*.txt\"`.\n- `[disallowed patterns]`: A list of disallowed patterns to search for in the files. Each pattern should be quoted and separated by a space. For example, `\"pattern1\" \"pattern2\"`.\n\nThe `--` argument is used to separate the file patterns from the disallowed patterns.\n\n### Example:\n\n```bash\n./pattern_checker.sh \"*.dart\" \"*.txt\" -- \"pattern1\" \"pattern2\"\n```\n\nThis command will scan all `.dart` and `.txt` files for the patterns `pattern1` and `pattern2`.\n\n## Output\n\nIf a disallowed pattern is found in a file, the script will print the filename, line number, and the matching line. If any disallowed patterns are found, the script will exit with a status of 1. If no disallowed patterns are found, the script will exit with a status of 0.\n\n---\n\nRemember to make the script executable with the command `chmod +x pattern_checker.sh` before running it.", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I exclude certain directories from being scanned by the `pattern_checker.sh` script?", - "title": "How can I exclude certain directories from being scanned by the `pattern_checker.sh` script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - }, - { - "message": { - "parts": [ - { - "range": { - "start": 0, - "endExclusive": 158 - }, - "editorRange": { - "startLineNumber": 1, - "startColumn": 1, - "endLineNumber": 1, - "endColumn": 159 - }, - "text": "Thanks. Can you write a small documentation comments for this script? I will put it at the top of the script file, so each line must begins with a single `#`.", - "kind": "text" - } - ], - "text": "Thanks. Can you write a small documentation comments for this script? I will put it at the top of the script file, so each line must begins with a single `#`." - }, - "response": [ - { - "value": "Absolutely, here's a brief documentation comment for the `pattern_checker.sh` script:\n\n```bash\n# Pattern Checker Script\n#\n# This script is a utility for scanning files for disallowed patterns. It accepts a list of file patterns and a list of disallowed patterns as arguments, and scans all files matching the file patterns for the disallowed patterns.\n#\n# Usage:\n# ./pattern_checker.sh [file patterns] -- [disallowed patterns]\n#\n# Parameters:\n# [file patterns]: A list of file patterns to scan. Each pattern should be quoted and separated by a space. For example, \"*.dart\" \"*.txt\".\n# [disallowed patterns]: A list of disallowed patterns to search for in the files. Each pattern should be quoted and separated by a space. For example, \"pattern1\" \"pattern2\".\n# The -- argument is used to separate the file patterns from the disallowed patterns.\n#\n# Example:\n# ./pattern_checker.sh \"*.dart\" \"*.txt\" -- \"pattern1\" \"pattern2\"\n# This command will scan all .dart and .txt files for the patterns pattern1 and pattern2.\n#\n# Output:\n# If a disallowed pattern is found in a file, the script will print the filename, line number, and the matching line. If any disallowed patterns are found, the script will exit with a status of 1. If no disallowed patterns are found, the script will exit with a status of 0.\n#\n# Remember to make the script executable with the command chmod +x pattern_checker.sh before running it.\n```\n\nYou can place this comment at the top of your script file. It provides a brief overview of what the script does, how to use it, and what output to expect.", - "isTrusted": false, - "supportThemeIcons": false, - "supportHtml": false - } - ], - "followups": [ - { - "kind": "reply", - "message": "How can I exclude specific directories from being scanned by the `pattern_checker.sh` script?", - "title": "How can I exclude specific directories from being scanned by the `pattern_checker.sh` script?" - } - ], - "isCanceled": false, - "agent": { - "id": "", - "metadata": { - "description": "", - "fullName": "GitHub Copilot", - "hasSlashCommands": true, - "hasFollowups": true, - "isDefault": true, - "helpTextPrefix": "You can ask me general programming questions, or chat with the following agents which have specialized expertise and can perform actions:", - "helpTextPostfix": { - "value": "To have a great conversation, ask me questions as if I was a real programmer:\n\n* **Show me the code** you want to talk about by having the files open and selecting the most important lines.\n* **Make refinements** by asking me follow-up questions, adding clarifications, providing errors, etc.\n* **Review my suggested code** and tell me about issues or improvements, so I can iterate on it.\n\nYou can also ask me questions about your editor selection by [starting an inline chat session](command:inlineChat.start).\n\nLearn more about [GitHub Copilot](https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode) in [Visual Studio Code](https://code.visualstudio.com/docs/editor/artificial-intelligence).", - "isTrusted": { - "enabledCommands": [ - "inlineChat.start" - ] - }, - "supportThemeIcons": false, - "supportHtml": false, - "uris": { - "command:inlineChat.start": { - "$mid": 1, - "path": "inlineChat.start", - "scheme": "command" - }, - "https://docs.github.com/copilot/getting-started-with-github-copilot?tool=vscode": { - "$mid": 1, - "path": "/copilot/getting-started-with-github-copilot", - "scheme": "https", - "authority": "docs.github.com", - "query": "tool=vscode" - }, - "https://code.visualstudio.com/docs/editor/artificial-intelligence": { - "$mid": 1, - "path": "/docs/editor/artificial-intelligence", - "scheme": "https", - "authority": "code.visualstudio.com" - } - } - }, - "supportIssueReporting": false - } - }, - "contentReferences": [] - } - ], - "providerId": "copilot" -} \ No newline at end of file diff --git a/test/draggable/draggable_sheet_test.dart b/test/draggable/draggable_sheet_test.dart new file mode 100644 index 00000000..c7bf83aa --- /dev/null +++ b/test/draggable/draggable_sheet_test.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; + +import '../src/keyboard_inset_simulation.dart'; + +class _TestApp extends StatelessWidget { + const _TestApp({ + this.useMaterial = false, + required this.child, + }); + + final bool useMaterial; + final Widget child; + + @override + Widget build(BuildContext context) { + if (useMaterial) { + return MaterialApp( + home: child, + ); + } else { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: child, + ), + ); + } + } +} + +class _TestSheetContent extends StatelessWidget { + const _TestSheetContent({ + this.height = 500, + }); + + final double? height; + + @override + Widget build(BuildContext context) { + return Container( + height: height, + width: double.infinity, + color: Colors.white, + ); + } +} + +void main() { + testWidgets('Inherited controller should be attached', (tester) async { + final controller = SheetController(); + await tester.pumpWidget( + SheetControllerScope( + controller: controller, + child: const _TestApp( + child: DraggableSheet( + child: _TestSheetContent(), + ), + ), + ), + ); + + expect(controller.hasClient, isTrue, + reason: 'The controller should have a client.'); + }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets('Opening keyboard does not interrupt sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestApp( + useMaterial: true, + child: KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: DraggableSheet( + key: sheetKey, + controller: controller, + minPosition: const SheetAnchor.pixels(200), + initialPosition: const SheetAnchor.pixels(200), + child: const Material( + child: _TestSheetContent( + height: 500, + ), + ), + ), + ), + ), + ); + + expect(controller.metrics.pixels, 200, + reason: 'The sheet should be at the initial position.'); + expect(controller.metrics.minPixels < controller.metrics.maxPixels, isTrue, + reason: 'The sheet should be draggable.'); + + // Start animating the sheet to the max position. + unawaited( + controller.animateTo( + const SheetAnchor.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the animation is running. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.metrics.pixels, + controller.metrics.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }); + + group('SheetKeyboardDismissible', () { + late FocusNode focusNode; + late ScrollController scrollController; + late Widget testWidget; + + setUp(() { + focusNode = FocusNode(); + scrollController = ScrollController(); + testWidget = _TestApp( + useMaterial: true, + child: SheetKeyboardDismissible( + dismissBehavior: const SheetKeyboardDismissBehavior.onDrag( + isContentScrollAware: true, + ), + child: DraggableSheet( + child: Material( + child: TextField( + focusNode: focusNode, + scrollController: scrollController, + maxLines: 2, + ), + ), + ), + ), + ); + }); + + tearDown(() { + focusNode.dispose(); + scrollController.dispose(); + }); + + testWidgets('should dismiss the keyboard when dragging', (tester) async { + await tester.pumpWidget(testWidget); + + final textField = find.byType(TextField); + await tester.showKeyboard(textField); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + + await tester.drag(textField, const Offset(0, -40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Downward dragging should dismiss the keyboard.'); + }); + + testWidgets('should dismiss the keyboard when scrolling', (tester) async { + await tester.pumpWidget(testWidget); + + final textField = find.byType(TextField); + await tester.enterText(textField, 'Hello, world! ' * 100); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + expect(scrollController.position.extentBefore, greaterThan(0), + reason: 'The text field should be able to scroll downwards.'); + + await tester.drag(textField, const Offset(0, 40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Downward dragging should dismiss the keyboard.'); + }); + }); +} diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 00000000..a63fb510 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,8 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + WidgetController.hitTestWarningShouldBeFatal = true; + await testMain(); +} diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart new file mode 100644 index 00000000..79d17f86 --- /dev/null +++ b/test/foundation/physics_test.dart @@ -0,0 +1,603 @@ +// ignore_for_file: lines_longer_than_80_chars +import 'package:flutter/foundation.dart' + show debugDefaultTargetPlatformOverride; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; + +class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics + with SheetPhysicsMixin { + const _SheetPhysicsWithDefaultConfiguration(); + + @override + SheetPhysics copyWith({SheetPhysics? parent, SpringDescription? spring}) { + return const _SheetPhysicsWithDefaultConfiguration(); + } +} + +const _referenceSheetMetrics = SheetMetricsSnapshot( + minPosition: SheetAnchor.pixels(0), + maxPosition: SheetAnchor.proportional(1), + pixels: 600, + contentSize: Size(360, 600), + viewportSize: Size(360, 700), + viewportInsets: EdgeInsets.zero, +); + +final _positionAtTopEdge = + _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.maxPixels); + +final _positionAtBottomEdge = + _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.minPixels); + +final _positionAtMiddle = _referenceSheetMetrics.copyWith( + pixels: (_positionAtTopEdge.pixels + _positionAtBottomEdge.pixels) / 2, +); + +void main() { + group('$SheetPhysics subclasses', () { + test('can create dynamic inheritance relationships', () { + const clamp = ClampingSheetPhysics(); + const bounce = BouncingSheetPhysics(); + const snap = SnappingSheetPhysics(); + + List getChain(SheetPhysics physics) { + return switch (physics.parent) { + null => [physics.runtimeType], + final parent => [physics.runtimeType, ...getChain(parent)], + }; + } + + expect( + getChain(clamp.applyTo(bounce).applyTo(snap)).join(' -> '), + 'ClampingSheetPhysics -> BouncingSheetPhysics -> SnappingSheetPhysics', + ); + + expect( + getChain(snap.applyTo(bounce).applyTo(clamp)).join(' -> '), + 'SnappingSheetPhysics -> BouncingSheetPhysics -> ClampingSheetPhysics', + ); + + expect( + getChain(bounce.applyTo(clamp).applyTo(snap)).join(' -> '), + 'BouncingSheetPhysics -> ClampingSheetPhysics -> SnappingSheetPhysics', + ); + }); + }); + + group('Default configuration of $SheetPhysics', () { + late SheetPhysics physicsUnderTest; + + setUp(() { + physicsUnderTest = const _SheetPhysicsWithDefaultConfiguration(); + }); + + test('dragStartDistanceMotionThreshold for different platforms', () { + for (final testTargetPlatform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = testTargetPlatform; + switch (testTargetPlatform) { + case TargetPlatform.iOS: + expect( + physicsUnderTest.dragStartDistanceMotionThreshold, + const BouncingScrollPhysics().dragStartDistanceMotionThreshold, + ); + case _: + expect(physicsUnderTest.dragStartDistanceMotionThreshold, null); + } + debugDefaultTargetPlatformOverride = null; + } + }); + + test('does not allow over/under dragging', () { + expect( + physicsUnderTest.computeOverflow(10, _positionAtTopEdge), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.computeOverflow(-10, _positionAtBottomEdge), + moreOrLessEquals(-10), + ); + }); + + test('does not apply any resistance if position is in bounds', () { + final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels - 10); + final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels + 10); + + expect( + physicsUnderTest.applyPhysicsToOffset(10, _positionAtMiddle), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(10, positionAtNearTopEdge), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(-10, positionAtNearBottomEdge), + moreOrLessEquals(-10), + ); + }); + + test('prevents position from going out of bounds', () { + expect( + physicsUnderTest.applyPhysicsToOffset(10, _positionAtTopEdge), + moreOrLessEquals(0), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(-10, _positionAtBottomEdge), + moreOrLessEquals(0), + ); + }); + + test('creates no ballistic simulation if position is in bounds', () { + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtMiddle), + isNull, + ); + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtTopEdge), + isNull, + ); + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtBottomEdge), + isNull, + ); + }); + + test('creates ballistic simulation which ends at the nearest edge', () { + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final underDragPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels - 10, + ); + final overDragSimulation = + physicsUnderTest.createBallisticSimulation(0, overDraggedPosition); + final underDraggedSimulation = + physicsUnderTest.createBallisticSimulation(0, underDragPosition); + + expect(overDragSimulation, isNotNull); + expect( + overDragSimulation!.x(5), // 5s passed + moreOrLessEquals(_referenceSheetMetrics.maxPixels), + ); + expect( + overDragSimulation.dx(5), // 5s passed + moreOrLessEquals(0), + ); + + expect(underDraggedSimulation, isNotNull); + expect( + underDraggedSimulation!.x(5), // 5s passed + moreOrLessEquals(_referenceSheetMetrics.minPixels), + ); + expect( + underDraggedSimulation.dx(5), // 5s passed + moreOrLessEquals(0), + ); + }); + + test('findSettledPosition', () { + expect( + physicsUnderTest.findSettledPosition(0, _positionAtMiddle), + SheetAnchor.pixels(_positionAtMiddle.pixels), + reason: 'Should return the current position if it is in bounds', + ); + expect( + physicsUnderTest.findSettledPosition(1000, _positionAtMiddle), + SheetAnchor.pixels(_positionAtMiddle.pixels), + reason: 'The velocity should not affect the result', + ); + + final overDraggedPosition = _positionAtTopEdge.copyWith( + pixels: _positionAtTopEdge.maxPixels + 10, + ); + expect( + physicsUnderTest.findSettledPosition(0, overDraggedPosition), + _referenceSheetMetrics.maxPosition, + reason: 'Should return the max position if the position ' + 'is out of the upper bound', + ); + expect( + physicsUnderTest.findSettledPosition(1000, overDraggedPosition), + _referenceSheetMetrics.maxPosition, + reason: 'The velocity should not affect the result', + ); + + final underDraggedPosition = _positionAtBottomEdge.copyWith( + pixels: _positionAtBottomEdge.minPixels - 10, + ); + expect( + physicsUnderTest.findSettledPosition(0, underDraggedPosition), + _referenceSheetMetrics.minPosition, + reason: 'Should return the min position if the position ' + 'is out of the lower bound', + ); + expect( + physicsUnderTest.findSettledPosition(1000, underDraggedPosition), + _referenceSheetMetrics.minPosition, + reason: 'The velocity should not affect the result', + ); + + // Boundary conditions + expect( + physicsUnderTest.findSettledPosition(1000, _positionAtTopEdge), + _referenceSheetMetrics.maxPosition, + reason: + 'Should return the max position if the position is at the upper bound', + ); + expect( + physicsUnderTest.findSettledPosition(1000, _positionAtBottomEdge), + _referenceSheetMetrics.minPosition, + reason: + 'Should return the min position if the position is at the lower bound', + ); + }); + }); + + group('$SnapToNearestEdge', () { + late SnapToNearestEdge behaviorUnderTest; + + setUp(() { + behaviorUnderTest = const SnapToNearestEdge(minFlingSpeed: 50); + }); + + test('snaps to nearest edge if velocity is small enough', () { + final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels - 50, + ); + final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels + 50, + ); + + expect( + behaviorUnderTest.findSettledPosition(0, positionAtNearTopEdge), + _referenceSheetMetrics.maxPosition, + ); + expect( + behaviorUnderTest.findSettledPosition(0, positionAtNearBottomEdge), + _referenceSheetMetrics.minPosition, + ); + }); + + test('is aware of fling gesture direction', () { + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtBottomEdge), + _referenceSheetMetrics.maxPosition, + ); + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtTopEdge), + _referenceSheetMetrics.minPosition, + ); + }); + + test('is disabled if position is out of bounds', () { + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final underDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels - 10, + ); + + expect( + behaviorUnderTest.findSettledPosition(0, overDraggedPosition), + isNull, + ); + expect( + behaviorUnderTest.findSettledPosition(0, underDraggedPosition), + isNull, + ); + }); + + test('Boundary conditions', () { + expect( + behaviorUnderTest.findSettledPosition(0, _positionAtTopEdge), isNull); + expect(behaviorUnderTest.findSettledPosition(0, _positionAtBottomEdge), + isNull); + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtTopEdge), + _referenceSheetMetrics.minPosition, + ); + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtBottomEdge), + _referenceSheetMetrics.maxPosition, + ); + }); + }); + group('$SnapToNearest', () { + late SnapToNearest behaviorUnderTest; + + setUp(() { + behaviorUnderTest = SnapToNearest( + minFlingSpeed: 50, + anchors: [ + SheetAnchor.pixels(_positionAtBottomEdge.pixels), + SheetAnchor.pixels(_positionAtMiddle.pixels), + SheetAnchor.pixels(_positionAtTopEdge.pixels), + ], + ); + }); + + test('snaps to nearest edge if velocity is small enough', () { + final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels - 50, + ); + final positionAtNearMiddle = _referenceSheetMetrics.copyWith( + pixels: _positionAtMiddle.pixels + 50, + ); + final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels + 50, + ); + + expect( + behaviorUnderTest.findSettledPosition(0, positionAtNearTopEdge), + SheetAnchor.pixels(_referenceSheetMetrics.maxPixels), + ); + expect( + behaviorUnderTest.findSettledPosition(0, positionAtNearMiddle), + SheetAnchor.pixels(_positionAtMiddle.pixels), + ); + expect( + behaviorUnderTest.findSettledPosition(0, positionAtNearBottomEdge), + SheetAnchor.pixels(_referenceSheetMetrics.minPixels), + ); + }); + + test('is aware of fling gesture direction', () { + final positionAtAboveMiddle = _positionAtMiddle.copyWith( + pixels: _positionAtMiddle.pixels + 10, + ); + final positionAtBelowMiddle = _positionAtMiddle.copyWith( + pixels: _positionAtMiddle.pixels - 10, + ); + // Flings up at the bottom edge + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtBottomEdge), + SheetAnchor.pixels(_positionAtMiddle.pixels), + ); + // Flings up at the slightly above the middle position + expect( + behaviorUnderTest.findSettledPosition(50, positionAtAboveMiddle), + SheetAnchor.pixels(_positionAtTopEdge.pixels), + ); + // Flings down at the top edge + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtTopEdge), + SheetAnchor.pixels(_positionAtMiddle.pixels), + ); + // Flings down at the slightly below the middle position + expect( + behaviorUnderTest.findSettledPosition(-50, positionAtBelowMiddle), + SheetAnchor.pixels(_positionAtBottomEdge.pixels), + ); + }); + + test('is disabled if position is out of bounds', () { + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final underDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels - 10, + ); + + expect( + behaviorUnderTest.findSettledPosition(0, overDraggedPosition), + isNull, + ); + expect( + behaviorUnderTest.findSettledPosition(0, underDraggedPosition), + isNull, + ); + }); + + test('Boundary condition: a drag ends exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledPosition(0, _positionAtTopEdge), + isNull, + ); + }); + + test('Boundary condition: flings up exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtTopEdge), + SheetAnchor.pixels(_positionAtTopEdge.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtTopEdge), + SheetAnchor.pixels(_positionAtMiddle.pixels), + ); + }); + + test('Boundary condition: a drag ends exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledPosition(0, _positionAtMiddle), + isNull, + ); + }); + + test('Boundary condition: flings up exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtMiddle), + SheetAnchor.pixels(_positionAtTopEdge.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtMiddle), + SheetAnchor.pixels(_positionAtBottomEdge.pixels), + ); + }); + + test('Boundary condition: a drag ends exactly at the bottom detent', () { + expect( + behaviorUnderTest.findSettledPosition(0, _positionAtBottomEdge), + isNull, + ); + }); + + test('Boundary condition: flings up exactly at the bottom detent', () { + expect( + behaviorUnderTest.findSettledPosition(50, _positionAtBottomEdge), + SheetAnchor.pixels(_positionAtMiddle.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the bottom detent', () { + expect( + behaviorUnderTest.findSettledPosition(-50, _positionAtBottomEdge), + SheetAnchor.pixels(_positionAtBottomEdge.pixels), + ); + }); + }); + + test('FixedBouncingBehavior returns same value for same input metrics', () { + expect( + const FixedBouncingBehavior(SheetAnchor.pixels(100)) + .computeBounceablePixels(50, _referenceSheetMetrics), + 100, + ); + expect( + const FixedBouncingBehavior(SheetAnchor.proportional(0.5)) + .computeBounceablePixels(50, _referenceSheetMetrics), + 300, + ); + }); + + test('DirectionAwareBouncingBehavior respects gesture direction', () { + const behavior = DirectionAwareBouncingBehavior( + upward: SheetAnchor.pixels(100), + downward: SheetAnchor.pixels(0), + ); + expect(behavior.computeBounceablePixels(50, _referenceSheetMetrics), 100); + expect(behavior.computeBounceablePixels(-50, _referenceSheetMetrics), 0); + }); + + group('BouncingSheetPhysics', () { + test('progressively applies friction if position is out of bounds', () { + const physics = BouncingSheetPhysics( + behavior: FixedBouncingBehavior(SheetAnchor.pixels(50)), + frictionCurve: Curves.linear, + ); + + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final moreOverDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 20, + ); + + expect(physics.applyPhysicsToOffset(10, overDraggedPosition), 6); + expect(physics.applyPhysicsToOffset(10, moreOverDraggedPosition), 4); + }); + + test('does not allow to go beyond bounceable bounds', () { + const physics = BouncingSheetPhysics( + behavior: FixedBouncingBehavior(SheetAnchor.pixels(30)), + frictionCurve: Curves.linear, + ); + + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 20, + ); + final underDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels - 20, + ); + + expect( + physics.applyPhysicsToOffset(20, overDraggedPosition), + moreOrLessEquals(0.53, epsilon: 0.01), + ); + expect( + physics.applyPhysicsToOffset(-20, underDraggedPosition), + moreOrLessEquals(-0.53, epsilon: 0.01), + ); + }); + + test('applies friction even if position is on boundary', () { + const physics = BouncingSheetPhysics( + behavior: FixedBouncingBehavior(SheetAnchor.pixels(50)), + frictionCurve: Curves.linear, + ); + + expect(physics.applyPhysicsToOffset(10, _positionAtTopEdge), 8); + expect(physics.applyPhysicsToOffset(-10, _positionAtBottomEdge), -8); + }); + + test('can apply a reasonable friction to extremely large offset', () { + const physics = BouncingSheetPhysics( + behavior: FixedBouncingBehavior(SheetAnchor.pixels(50)), + frictionCurve: Curves.linear, + ); + + expect( + physics.applyPhysicsToOffset(300, _positionAtTopEdge), + moreOrLessEquals(33.42, epsilon: 0.01), + ); + expect( + physics.applyPhysicsToOffset(-300, _positionAtBottomEdge), + moreOrLessEquals(-33.42, epsilon: 0.01), + ); + }); + }); + + group('sortPositionsAndFindNearest', () { + test('with two positions', () { + final (sortedPositions, nearestIndex) = sortPositionsAndFindNearest( + const [SheetAnchor.proportional(1), SheetAnchor.pixels(0)], + 250, + const Size(400, 600), + ); + expect(sortedPositions, const [ + (position: SheetAnchor.pixels(0), resolved: 0), + (position: SheetAnchor.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 0); + }); + + test('with three positions', () { + final (sortedPositions, nearestIndex) = sortPositionsAndFindNearest( + const [ + SheetAnchor.proportional(1), + SheetAnchor.proportional(0.5), + SheetAnchor.pixels(0), + ], + 250, + const Size(400, 600), + ); + expect(sortedPositions, const [ + (position: SheetAnchor.pixels(0), resolved: 0), + (position: SheetAnchor.proportional(0.5), resolved: 300), + (position: SheetAnchor.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 1); + }); + + test('with more than three positions', () { + final (sortedPositions, nearestIndex) = sortPositionsAndFindNearest( + const [ + SheetAnchor.proportional(0.25), + SheetAnchor.proportional(0.5), + SheetAnchor.proportional(0.75), + SheetAnchor.pixels(0), + SheetAnchor.proportional(1), + ], + 500, + const Size(400, 600), + ); + expect(sortedPositions, const [ + (position: SheetAnchor.pixels(0), resolved: 0), + (position: SheetAnchor.proportional(0.25), resolved: 150), + (position: SheetAnchor.proportional(0.5), resolved: 300), + (position: SheetAnchor.proportional(0.75), resolved: 450), + (position: SheetAnchor.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 3); + }); + }); +} diff --git a/test/foundation/sheet_activity_test.dart b/test/foundation/sheet_activity_test.dart new file mode 100644 index 00000000..021c77d1 --- /dev/null +++ b/test/foundation/sheet_activity_test.dart @@ -0,0 +1,353 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; + +import '../src/matchers.dart'; +import '../src/stubbing.dart'; + +class _TestAnimatedSheetActivity extends AnimatedSheetActivity { + _TestAnimatedSheetActivity({ + required AnimationController controller, + required super.destination, + required super.duration, + required super.curve, + }) : _controller = controller; + + final AnimationController _controller; + + @override + AnimationController createAnimationController() { + return _controller; + } +} + +void main() { + group('AnimatedSheetActivity', () { + late MockAnimationController controller; + + setUp(() { + controller = MockAnimationController(); + when( + controller.animateTo( + any, + duration: anyNamed('duration'), + curve: anyNamed('curve'), + ), + ).thenAnswer((_) => MockTickerFuture()); + }); + + test('should animate to the destination', () { + final (ownerMetrics, owner) = createMockSheetPosition( + pixels: 300, + minPosition: const SheetAnchor.pixels(300), + maxPosition: const SheetAnchor.pixels(700), + contentSize: const Size(400, 700), + viewportSize: const Size(400, 900), + viewportInsets: EdgeInsets.zero, + devicePixelRatio: 1, + ); + + final activity = _TestAnimatedSheetActivity( + controller: controller, + destination: const SheetAnchor.pixels(700), + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + )..init(owner); + + verify( + controller.animateTo( + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ); + + when(controller.value).thenReturn(0.0); + activity.onAnimationTick(); + expect(ownerMetrics.pixels, 300); + + when(controller.value).thenReturn(0.5); + activity.onAnimationTick(); + expect(ownerMetrics.pixels, 500); + + when(controller.value).thenReturn(1.0); + activity.onAnimationTick(); + expect(ownerMetrics.pixels, 700); + }); + + test('should absorb viewport changes', () { + final (ownerMetrics, owner) = createMockSheetPosition( + pixels: 300, + minPosition: const SheetAnchor.pixels(300), + maxPosition: const SheetAnchor.proportional(1), + contentSize: const Size(400, 900), + viewportSize: const Size(400, 900), + viewportInsets: EdgeInsets.zero, + devicePixelRatio: 1, + ); + + final activity = _TestAnimatedSheetActivity( + controller: controller, + destination: const SheetAnchor.proportional(1), + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + )..init(owner); + + when(controller.value).thenReturn(0.0); + activity.onAnimationTick(); + expect(ownerMetrics.pixels, 300); + + when(controller.value).thenReturn(0.25); + when(controller.lastElapsedDuration) + .thenReturn(const Duration(milliseconds: 75)); + activity.onAnimationTick(); + expect(ownerMetrics.pixels, 450); + + // The following lines simulate a viewport change, in which: + // 1. The viewport's bottom inset increases, simulating the + // appearance of an on-screen keyboard. + // 2. The content size then reduces to avoid overlapping with the + // increased bottom inset. + // This scenario mimics the behavior when opening a keyboard + // on a sheet that uses SheetContentScaffold. + final oldViewportInsets = ownerMetrics.viewportInsets; + final oldContentSize = ownerMetrics.contentSize; + ownerMetrics + ..maybeViewportInsets = const EdgeInsets.only(bottom: 50) + ..maybeContentSize = const Size(400, 850); + + activity.didChangeViewportDimensions(null, oldViewportInsets); + activity.didChangeContentSize(oldContentSize); + activity.didFinalizeDimensions(oldContentSize, null, oldViewportInsets); + expect(ownerMetrics.pixels, 400); + expect(ownerMetrics.viewPixels, 450, + reason: 'Visual position should not change when viewport changes.'); + verify(owner.settleTo( + const SheetAnchor.proportional(1), + const Duration(milliseconds: 225), + )); + }); + }); + + group('SettlingSheetActivity', () { + late MockSheetPosition owner; + late MutableSheetMetrics ownerMetrics; + late MockTicker internalTicker; + late TickerCallback? internalOnTickCallback; + + setUp(() { + (ownerMetrics, owner) = createMockSheetPosition( + pixels: 300, + minPosition: const SheetAnchor.proportional(0.5), + maxPosition: const SheetAnchor.proportional(1), + contentSize: const Size(400, 600), + viewportSize: const Size(400, 900), + viewportInsets: EdgeInsets.zero, + devicePixelRatio: 1, + ); + internalTicker = MockTicker(); + final tickerProvider = MockTickerProvider(); + final context = MockSheetContext(); + when(context.vsync).thenReturn(tickerProvider); + when(tickerProvider.createTicker(any)).thenAnswer((invocation) { + internalOnTickCallback = + invocation.positionalArguments[0] as TickerCallback; + return internalTicker; + }); + when(owner.context).thenReturn(context); + }); + + tearDown(() { + internalOnTickCallback = null; + }); + + test('Create with velocity', () { + final activity = SettlingSheetActivity( + destination: const SheetAnchor.pixels(0), + velocity: 100, + ); + + expect(activity.destination, const SheetAnchor.pixels(0)); + expect(activity.duration, isNull); + expect(activity.velocity, 100); + expect(activity.shouldIgnorePointer, isFalse); + }); + + test('Create with duration', () { + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const SheetAnchor.pixels(0), + ); + + expect(activity.destination, const SheetAnchor.pixels(0)); + expect(activity.duration, const Duration(milliseconds: 300)); + expect(activity.shouldIgnorePointer, isFalse); + expect(() => activity.velocity, isNotInitialized); + }); + + test( + 'Velocity should be set when the activity is initialized', + () { + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const SheetAnchor.proportional(1), + ); + expect(() => activity.velocity, isNotInitialized); + + activity.init(owner); + expect(activity.velocity, 1000); // (300pixels / 300ms) = 1000 pixels/s + }, + ); + + test('Progressively updates current position toward destination', () { + final activity = SettlingSheetActivity( + destination: const SheetAnchor.proportional(1), + velocity: 300, + ); + + activity.init(owner); + verify(internalTicker.start()); + + internalOnTickCallback!(const Duration(milliseconds: 200)); + expect(ownerMetrics.pixels, 360); // 300 * 0.2 = 60 pixels in 200ms + + internalOnTickCallback!(const Duration(milliseconds: 400)); + expect(ownerMetrics.pixels, 420); // 300 * 0.2 = 60 pixels in 200ms + + internalOnTickCallback!(const Duration(milliseconds: 500)); + expect(ownerMetrics.pixels, 450); // 300 * 0.1 = 30 pixels in 100ms + + internalOnTickCallback!(const Duration(milliseconds: 800)); + expect(ownerMetrics.pixels, 540); // 300 * 0.3 = 90 pixels in 300ms + + internalOnTickCallback!(const Duration(milliseconds: 1000)); + expect(ownerMetrics.pixels, 600); // 300 * 0.2 = 60 pixels in 200ms + }); + + test( + 'Should start an idle activity when it reaches destination', + () { + final _ = SettlingSheetActivity( + destination: const SheetAnchor.proportional(1), + velocity: 300, + )..init(owner); + + ownerMetrics.maybePixels = 540; + internalOnTickCallback!(const Duration(milliseconds: 1000)); + verify(owner.goIdle()); + }, + ); + + test('Should absorb viewport changes', () { + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const SheetAnchor.proportional(1), + )..init(owner); + + expect(activity.velocity, 1000); // (300 pixels / 0.3s) = 1000 pixels/s + + internalOnTickCallback!(const Duration(milliseconds: 50)); + expect(ownerMetrics.pixels, 350); // 1000 * 0.05 = 50 pixels in 50ms + + final oldViewportInsets = ownerMetrics.viewportInsets; + final oldContentSize = ownerMetrics.contentSize; + // Show the on-screen keyboard. + ownerMetrics.maybeViewportInsets = const EdgeInsets.only(bottom: 30); + activity.didChangeViewportDimensions(null, oldViewportInsets); + activity.didChangeContentSize(oldContentSize); + activity.didFinalizeDimensions(oldContentSize, null, oldViewportInsets); + expect(ownerMetrics.pixels, 320, + reason: 'Visual position should not change when viewport changes.'); + expect(activity.velocity, 1120, // 280 pixels / 0.25s = 1120 pixels/s + reason: 'Velocity should be updated when viewport changes.'); + + internalOnTickCallback!(const Duration(milliseconds: 100)); + expect(ownerMetrics.pixels, 376); // 1120 * 0.05 = 56 pixels in 50ms + }); + }); + + group('IdleSheetActivity', () { + test('should maintain previous position when keyboard appears', () { + final (ownerMetrics, owner) = createMockSheetPosition( + pixels: 450, + minPosition: const SheetAnchor.proportional(0.5), + maxPosition: const SheetAnchor.proportional(1), + contentSize: const Size(400, 850), + viewportSize: const Size(400, 900), + viewportInsets: const EdgeInsets.only(bottom: 50), + devicePixelRatio: 1, + physics: kDefaultSheetPhysics, + ); + + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 900); + const oldViewportInsets = EdgeInsets.zero; + activity + ..didChangeContentSize(oldContentSize) + ..didChangeViewportDimensions(oldContentSize, oldViewportInsets) + ..didFinalizeDimensions(oldContentSize, null, oldViewportInsets); + expect(ownerMetrics.pixels, 425); + }); + + test( + 'should maintain previous position when content size changes, ' + 'without animation if gap is small', + () { + final (ownerMetrics, owner) = createMockSheetPosition( + pixels: 300, + minPosition: const SheetAnchor.proportional(0.5), + maxPosition: const SheetAnchor.proportional(1), + contentSize: const Size(400, 580), + viewportSize: const Size(400, 900), + viewportInsets: EdgeInsets.zero, + devicePixelRatio: 1, + physics: kDefaultSheetPhysics, + ); + + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 600); + activity + ..didChangeContentSize(oldContentSize) + ..didFinalizeDimensions(oldContentSize, null, null); + expect(ownerMetrics.pixels, 290); + // Still in the idle activity. + verifyNever(owner.beginActivity(any)); + }, + ); + + test( + 'should maintain previous position when content size changes, ' + 'with animation if gap is large', + () { + final (ownerMetrics, owner) = createMockSheetPosition( + pixels: 300, + minPosition: const SheetAnchor.proportional(0.5), + maxPosition: const SheetAnchor.proportional(1), + contentSize: const Size(400, 500), + viewportSize: const Size(400, 900), + viewportInsets: EdgeInsets.zero, + devicePixelRatio: 1, + physics: kDefaultSheetPhysics, + ); + + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 600); + activity + ..didChangeContentSize(oldContentSize) + ..didFinalizeDimensions(oldContentSize, null, null); + expect(ownerMetrics.pixels, 300); + verify( + owner.animateTo( + const SheetAnchor.proportional(0.5), + duration: anyNamed('duration'), + curve: anyNamed('curve'), + ), + ); + }, + ); + }); +} diff --git a/test/foundation/sheet_notification_test.dart b/test/foundation/sheet_notification_test.dart new file mode 100644 index 00000000..a260afa7 --- /dev/null +++ b/test/foundation/sheet_notification_test.dart @@ -0,0 +1,362 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/src/draggable/draggable_sheet.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; +import 'package:smooth_sheets/src/foundation/sheet_notification.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; +import 'package:smooth_sheets/src/foundation/sheet_position.dart'; +import 'package:smooth_sheets/src/foundation/sheet_status.dart'; + +void main() { + testWidgets( + 'Drag gesture should dispatch drag start/update/end notifications in sequence', + (tester) async { + final reportedNotifications = []; + const targetKey = Key('target'); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + minPosition: const SheetAnchor.pixels(0), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + key: targetKey, + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + final gesturePointer = await tester.press(find.byKey(targetKey)); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect(reportedNotifications, hasLength(2)); + expect( + reportedNotifications[0], + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.dragDetails.kind, 'kind', PointerDeviceKind.touch) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 250), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 350), + ), + ); + expect( + reportedNotifications[1], + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 270), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 370), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 460) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 290), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 390), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, -20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 270), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 370), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.up(); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.dragDetails.velocity, 'velocity', Velocity.zero) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ), + ); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the drag is ended, ' + 'no notification should be dispatched.'); + }, + ); + + testWidgets( + 'Sheet animation should dispatch metrics update notifications', + (tester) async { + final reportedNotifications = []; + final controller = SheetController(); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + controller: controller, + minPosition: const SheetAnchor.pixels(0), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + color: Colors.white, + width: double.infinity, + height: 600, + ), + ), + ), + ); + + unawaited( + controller.animateTo( + const SheetAnchor.pixels(0), + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ); + await tester.pump(Duration.zero); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(600)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(400)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(200)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(seconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(0)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the animation is finished, ' + 'no notification should be dispatched.'); + }, + ); + + testWidgets( + 'Over-darg gesture should dispatch both darg and overflow notifications', + (tester) async { + final reportedNotifications = []; + const targetKey = Key('target'); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + // Make sure the sheet can't be dragged + minPosition: const SheetAnchor.proportional(1), + maxPosition: const SheetAnchor.proportional(1), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + key: targetKey, + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + final gesturePointer = await tester.press(find.byKey(targetKey)); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect(reportedNotifications, hasLength(2)); + expect( + reportedNotifications[0], + isA().having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + // Since the y-axis is upward and we are performing a downward drag, + // the sign of the overflowed delta should be negative. + VerticalDirection.up, + ), + ); + expect( + reportedNotifications[1], + isA() + .having((e) => e.metrics.pixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.overflow, 'overflow', -20), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.overflow, 'overflow', -20), + ); + + reportedNotifications.clear(); + await gesturePointer.up(); + await tester.pump(); + expect(reportedNotifications.single, isA()); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the drag is ended, ' + 'no notification should be dispatched.'); + }, + ); + + /* + TODO: Uncomment this once https://github.com/flutter/flutter/issues/152163 is fixed. + testWidgets( + 'Canceling drag gesture should dispatch a drag cancel notification', + (tester) async { + final reportedNotifications = []; + const targetKey = Key('target'); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + minPosition: const SheetAnchor.pixels(0), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + key: targetKey, + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + final gesturePointer = await tester.press(find.byKey(targetKey)); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect( + reportedNotifications, + equals([ + isA(), + isA(), + ]), + ); + + reportedNotifications.clear(); + await gesturePointer.cancel(); + await tester.pump(); + expect( + reportedNotifications.single, + isA(), + ); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the drag is canceled, ' + 'no notification should be dispatched.'); + }, + ); + */ +} diff --git a/test/foundation/sheet_viewport_test.dart b/test/foundation/sheet_viewport_test.dart new file mode 100644 index 00000000..635d977f --- /dev/null +++ b/test/foundation/sheet_viewport_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; +import 'package:smooth_sheets/src/foundation/sheet_context.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; +import 'package:smooth_sheets/src/foundation/sheet_position.dart'; +import 'package:smooth_sheets/src/foundation/sheet_position_scope.dart'; +import 'package:smooth_sheets/src/foundation/sheet_status.dart'; +import 'package:smooth_sheets/src/foundation/sheet_viewport.dart'; + +class _FakeNotificationContext extends Fake implements BuildContext { + @override + void dispatchNotification(Notification notification) { + /* no-op */ + } +} + +class _FakeSheetContext extends Fake implements SheetContext { + @override + final notificationContext = _FakeNotificationContext(); + + @override + double get devicePixelRatio => 3.0; + + @override + TickerProvider get vsync => const TestVSync(); +} + +class _FakeSheetActivity extends SheetActivity { + _FakeSheetActivity({ + this.shouldIgnorePointer = false, + }); + + @override + final bool shouldIgnorePointer; + + @override + SheetStatus get status => SheetStatus.stable; +} + +class _FakeSheetPosition extends SheetPosition { + _FakeSheetPosition({ + this.createIdleActivity, + }) : super( + context: _FakeSheetContext(), + minPosition: const SheetAnchor.proportional(0.5), + maxPosition: const SheetAnchor.proportional(1), + physics: const ClampingSheetPhysics(), + ); + + final ValueGetter? createIdleActivity; + + @override + void applyNewContentSize(Size contentSize) { + super.applyNewContentSize(contentSize); + if (maybePixels == null) { + setPixels(maxPosition.resolve(contentSize)); + } + } + + @override + void goIdle() { + if (createIdleActivity case final builder?) { + beginActivity(builder()); + } else { + super.goIdle(); + } + } +} + +class _TestWidget extends StatelessWidget { + const _TestWidget({ + required this.position, + this.background, + this.sheetContent, + }); + + final SheetPosition position; + final Widget? sheetContent; + final Widget? background; + + @override + Widget build(BuildContext context) { + final sheet = InheritedSheetPositionScope( + isPrimary: true, + position: position, + child: SheetViewport( + child: SheetContentViewport( + child: sheetContent ?? + Container( + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: switch (background) { + null => sheet, + final background => Stack( + children: [background, sheet], + ) + }, + ), + ); + } +} + +void main() { + group('Ignore pointer test:', () { + ({ + SheetPosition position, + Widget testWidget, + ValueGetter didTapForeground, + ValueGetter didTapBackgroundTop, + ValueGetter didTapBackgroundBottom, + }) boilerplate({ + required bool shouldIgnorePointer, + }) { + var didTapForeground = false; + var didTapBackgroundTop = false; + var didTapBackgroundBottom = false; + + final position = _FakeSheetPosition( + createIdleActivity: () => _FakeSheetActivity( + shouldIgnorePointer: shouldIgnorePointer, + ), + ); + + final testWidget = _TestWidget( + position: position, + background: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => didTapBackgroundTop = true, + child: const Text('Background top'), + ), + TextButton( + onPressed: () => didTapBackgroundBottom = true, + child: const Text('Background bottom'), + ), + ], + ), + sheetContent: Container( + alignment: Alignment.center, + color: Colors.white, + width: double.infinity, + height: 500, + child: TextButton( + onPressed: () => didTapForeground = true, + child: const Text('Foreground'), + ), + ), + ); + + return ( + position: position, + testWidget: testWidget, + didTapForeground: () => didTapForeground, + didTapBackgroundTop: () => didTapBackgroundTop, + didTapBackgroundBottom: () => didTapBackgroundBottom, + ); + } + + testWidgets( + 'pointer events on a sheet should be ignored if activity says to do so', + (tester) async { + final env = boilerplate(shouldIgnorePointer: true); + await tester.pumpWidget(env.testWidget); + await tester.tap(find.text('Foreground'), warnIfMissed: false); + expect(env.didTapForeground(), isFalse); + }, + ); + + testWidgets( + 'content in a sheet should receive pointer events if activity allows', + (tester) async { + final env = boilerplate(shouldIgnorePointer: false); + await tester.pumpWidget(env.testWidget); + await tester.tap(find.text('Foreground'), warnIfMissed: false); + expect(env.didTapForeground(), isTrue); + }, + ); + + testWidgets( + 'content obscured by a sheet should never receive pointer events', + (tester) async { + final env = boilerplate(shouldIgnorePointer: true); + await tester.pumpWidget(env.testWidget); + await tester.tap(find.text('Background bottom'), warnIfMissed: false); + expect(env.didTapBackgroundBottom(), isFalse); + }, + ); + + testWidgets( + 'content not obscured by a sheet should always receive pointer events', + (tester) async { + final env = boilerplate(shouldIgnorePointer: true); + await tester.pumpWidget(env.testWidget); + await tester.tap(find.text('Background top'), warnIfMissed: false); + expect(env.didTapBackgroundTop(), isTrue); + }, + ); + }); +} diff --git a/test/modal/modal_sheet_test.dart b/test/modal/modal_sheet_test.dart new file mode 100644 index 00000000..42910647 --- /dev/null +++ b/test/modal/modal_sheet_test.dart @@ -0,0 +1,536 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +class _Boilerplate extends StatelessWidget { + const _Boilerplate({ + required this.modalRoute, + }); + + final ModalSheetRoute modalRoute; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.push(context, modalRoute); + }, + child: const Text('Open modal'), + ), + ), + ); + }, + ), + ); + } +} + +class _BoilerplateWithGoRouter extends StatelessWidget { + const _BoilerplateWithGoRouter({ + required this.modalPage, + this.onExitModal, + }); + + final ModalSheetPage modalPage; + final FutureOr Function()? onExitModal; + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => context.go('/modal'), + child: const Text('Open modal'), + ), + ), + ); + }, + routes: [ + GoRoute( + path: 'modal', + pageBuilder: (context, state) => modalPage, + onExit: onExitModal != null + ? (context, state) => onExitModal!() + : null, + ), + ], + ), + ], + ), + ); + } +} + +void main() { + group('Swipe-to-dismiss action test', () { + Widget boilerplate(SwipeDismissSensitivity sensitivity) { + return _Boilerplate( + modalRoute: ModalSheetRoute( + swipeDismissible: true, + swipeDismissSensitivity: sensitivity, + builder: (context) { + return DraggableSheet( + child: Container( + key: const Key('sheet'), + color: Colors.white, + width: double.infinity, + height: 600, + ), + ); + }, + ), + ); + } + + testWidgets( + 'modal should be dismissed if swipe gesture has enough speed', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + boilerplate( + const SwipeDismissSensitivity( + minFlingVelocityRatio: 1.0, + minDragDistance: 1000, + ), + ), + ); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + + await tester.fling( + find.byKey(const Key('sheet')), + const Offset(0, 200), + 901, // ratio = velocity (901.0) / screen-height (900.0) > threshold-ratio + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsNothing); + }, + ); + + testWidgets( + 'modal should not be dismissed if swipe gesture has not enough speed', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + boilerplate( + const SwipeDismissSensitivity( + minFlingVelocityRatio: 1.0, + minDragDistance: 1000, + ), + ), + ); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + + await tester.fling( + find.byKey(const Key('sheet')), + const Offset(0, 200), + 899, // ratio = velocity (899.0) / screen-height (900.0) < threshold-ratio + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + }, + ); + + testWidgets( + 'modal should be dismissed if drag distance is enough', + (tester) async { + await tester.pumpWidget( + boilerplate( + const SwipeDismissSensitivity( + minFlingVelocityRatio: 5.0, + minDragDistance: 100, + ), + ), + ); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + + await tester.drag( + find.byKey(const Key('sheet')), + const Offset(0, 101), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsNothing); + }, + ); + + testWidgets( + 'modal should not be dismissed if drag distance is not enough', + (tester) async { + await tester.pumpWidget( + boilerplate( + const SwipeDismissSensitivity( + minFlingVelocityRatio: 5.0, + minDragDistance: 100, + ), + ), + ); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + + await tester.drag( + find.byKey(const Key('sheet')), + const Offset(0, 99), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sheet')), findsOneWidget); + }, + ); + }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/233 + group('PopScope test', () { + late bool isOnPopInvokedCalled; + late Widget testWidget; + + setUp(() { + isOnPopInvokedCalled = false; + testWidget = _Boilerplate( + modalRoute: ModalSheetRoute( + swipeDismissible: true, + builder: (context) { + return DraggableSheet( + child: PopScope( + canPop: false, + onPopInvoked: (didPop) { + isOnPopInvokedCalled = true; + }, + child: Container( + key: const Key('sheet'), + color: Colors.white, + width: double.infinity, + height: 200, + ), + ), + ); + }, + ), + ); + }); + + testWidgets( + 'PopScope.onPopInvoked should be called when tap on barrier', + (tester) async { + await tester.pumpWidget(testWidget); + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(AnimatedModalBarrier)); + await tester.pumpAndSettle(); + expect(isOnPopInvokedCalled, isTrue); + }, + ); + + testWidgets( + 'PopScope.onPopInvoked should be called when swipe to dismiss', + (tester) async { + await tester.pumpWidget(testWidget); + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + await tester.fling( + find.byKey(const Key('sheet')), + const Offset(0, 200), + 2000, + ); + await tester.pumpAndSettle(); + expect(isOnPopInvokedCalled, isTrue); + }, + ); + }); + + // Regression tests for https://github.com/fujidaiti/smooth_sheets/issues/250 + // TODO: Add test cases using Navigator 2.0. + group('Transition animation status and animation curve consistency test', () { + ({ + Widget testWidget, + ModalSheetRoute modalRoute, + ValueGetter popInvoked, + }) boilerplate() { + var popInvoked = false; + final modalRoute = ModalSheetRoute( + swipeDismissible: true, + transitionCurve: Curves.easeInOut, + builder: (context) { + return DraggableSheet( + child: PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (!didPop) { + popInvoked = true; + Navigator.pop(context); + } + }, + child: Container( + key: const Key('sheet'), + color: Colors.white, + width: double.infinity, + height: 400, + ), + ), + ); + }, + ); + + return ( + testWidget: _Boilerplate(modalRoute: modalRoute), + modalRoute: modalRoute, + popInvoked: () => popInvoked, + ); + } + + testWidgets('Swipe-to-dismissed', (tester) async { + final env = boilerplate(); + await tester.pumpWidget(env.testWidget); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(env.modalRoute.animation!.isCompleted, isTrue); + expect(env.modalRoute.effectiveCurve, Curves.easeInOut); + + // Start dragging. + final gesture = await tester.press(find.byKey(const Key('sheet'))); + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + // End dragging and then a pop animation starts. + await gesture.moveBy(const Offset(0, 100)); + await gesture.up(); + expect(env.popInvoked(), isTrue); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + // Ensure that the pop animation is completed. + await tester.pumpAndSettle(); + expect(env.modalRoute.animation!.isDismissed, isTrue); + expect(env.modalRoute.effectiveCurve, Curves.easeInOut); + }); + + testWidgets('Swipe-to-dismiss canceled', (tester) async { + final env = boilerplate(); + await tester.pumpWidget(env.testWidget); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(env.modalRoute.animation!.isCompleted, isTrue); + expect(env.modalRoute.effectiveCurve, Curves.easeInOut); + + // Start dragging. + final gesture = await tester.press(find.byKey(const Key('sheet'))); + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + // Release the drag, triggering the modal + // to settle back to its original position. + await gesture.up(); + expect(env.popInvoked(), isFalse); + expect(env.modalRoute.animation!.status, AnimationStatus.forward); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute.animation!.isCompleted, isFalse); + expect(env.modalRoute.animation!.isDismissed, isFalse); + expect(env.modalRoute.effectiveCurve, Curves.linear); + + // Ensure that the pop animation is completed. + await tester.pumpAndSettle(); + expect(env.modalRoute.animation!.isCompleted, isTrue); + expect(env.modalRoute.effectiveCurve, Curves.easeInOut); + }); + }); + + // Regression tests for https://github.com/fujidaiti/smooth_sheets/issues/250 + group( + 'Transition animation status and animation curve consistency test ' + 'with Navigator 2.0', + () { + ({ + Widget testWidget, + ValueGetter?> modalRoute, + ValueGetter popInvoked, + }) boilerplate() { + var popInvoked = false; + ModalSheetRouteMixin? modalRoute; + final testWidget = _BoilerplateWithGoRouter( + onExitModal: () { + popInvoked = true; + return true; + }, + modalPage: ModalSheetPage( + swipeDismissible: true, + transitionCurve: Curves.easeInOut, + child: Builder( + builder: (context) { + modalRoute = + ModalRoute.of(context)! as ModalSheetRouteMixin; + + return DraggableSheet( + child: Container( + key: const Key('sheet'), + color: Colors.white, + width: double.infinity, + height: 400, + ), + ); + }, + ), + ), + ); + + return ( + testWidget: testWidget, + modalRoute: () => modalRoute, + popInvoked: () => popInvoked, + ); + } + + testWidgets('Swipe-to-dismissed', (tester) async { + final env = boilerplate(); + await tester.pumpWidget(env.testWidget); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(env.modalRoute()!.animation!.isCompleted, isTrue); + expect(env.modalRoute()!.effectiveCurve, Curves.easeInOut); + + // Start dragging. + final gesture = await tester.press(find.byKey(const Key('sheet'))); + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + // End dragging and then a pop animation starts. + await gesture.moveBy(const Offset(0, 100)); + await gesture.up(); + expect(env.popInvoked(), isTrue); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + // Ensure that the pop animation is completed. + await tester.pumpAndSettle(); + expect(env.modalRoute()!.animation!.isDismissed, isTrue); + expect(env.modalRoute()!.effectiveCurve, Curves.easeInOut); + }); + + testWidgets('Swipe-to-dismiss canceled', (tester) async { + final env = boilerplate(); + await tester.pumpWidget(env.testWidget); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + expect(env.modalRoute()!.animation!.isCompleted, isTrue); + expect(env.modalRoute()!.effectiveCurve, Curves.easeInOut); + + // Start dragging. + final gesture = await tester.press(find.byKey(const Key('sheet'))); + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await gesture.moveBy(const Offset(0, 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + // Release the drag, triggering the modal + // to settle back to its original position. + await gesture.up(); + expect(env.popInvoked(), isFalse); + expect(env.modalRoute()!.animation!.status, AnimationStatus.forward); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + await tester.pump(const Duration(milliseconds: 50)); + expect(env.modalRoute()!.animation!.isCompleted, isFalse); + expect(env.modalRoute()!.animation!.isDismissed, isFalse); + expect(env.modalRoute()!.effectiveCurve, Curves.linear); + + // Ensure that the pop animation is completed. + await tester.pumpAndSettle(); + expect(env.modalRoute()!.animation!.isCompleted, isTrue); + expect(env.modalRoute()!.effectiveCurve, Curves.easeInOut); + }); + }, + ); +} diff --git a/test/navigation/navigation_sheet_test.dart b/test/navigation/navigation_sheet_test.dart new file mode 100644 index 00000000..4fa78958 --- /dev/null +++ b/test/navigation/navigation_sheet_test.dart @@ -0,0 +1,622 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; + +import '../src/keyboard_inset_simulation.dart'; + +class _TestWidget extends StatelessWidget { + const _TestWidget( + this.sheetTransitionObserver, { + required this.initialRoute, + required this.routes, + this.sheetKey, + this.contentBuilder, + this.sheetBuilder, + this.sheetController, + this.useMaterialApp = false, + }); + + final String initialRoute; + final Map>> routes; + final Widget Function(BuildContext, Widget)? contentBuilder; + final Widget Function(BuildContext, Widget)? sheetBuilder; + final SheetController? sheetController; + final NavigationSheetTransitionObserver sheetTransitionObserver; + final Key? sheetKey; + final bool useMaterialApp; + + @override + Widget build(BuildContext context) { + Widget navigationSheet = NavigationSheet( + key: sheetKey, + controller: sheetController, + transitionObserver: sheetTransitionObserver, + child: ColoredBox( + color: Colors.white, + child: Navigator( + observers: [sheetTransitionObserver], + initialRoute: initialRoute, + onGenerateRoute: (settings) => routes[settings.name]!(), + ), + ), + ); + + if (sheetBuilder case final builder?) { + navigationSheet = builder(context, navigationSheet); + } + + Widget content = Stack( + children: [ + TextButton( + onPressed: () {}, + child: const Text('Background text'), + ), + navigationSheet, + ], + ); + + if (contentBuilder case final builder?) { + content = builder(context, content); + } + + return switch (useMaterialApp) { + true => MaterialApp(home: content), + false => Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: content, + ), + ), + }; + } +} + +class _TestDraggablePageWidget extends StatelessWidget { + const _TestDraggablePageWidget({ + super.key, + required this.height, + required this.label, + this.onTapNext, + this.onTapBack, + }); + + final double height; + final String label; + final VoidCallback? onTapNext; + final VoidCallback? onTapBack; + + @override + Widget build(BuildContext context) { + return Container( + // Add an opaque background color, otherwise the container + // does not respond to drag gestures. + color: Colors.blue, + width: double.infinity, + height: height, + // Do not place the buttons in the center of the container, + // so that the drag gestures performed in `tester.darg` and + // starting from the center of the container are not stolen + // by the buttons. + alignment: Alignment.topLeft, + child: Column( + children: [ + Text(label), + TextButton( + onPressed: onTapNext, + child: const Text('Next'), + ), + TextButton( + onPressed: onTapBack, + child: const Text('Back'), + ), + ], + ), + ); + } + + static Route createRoute({ + Key? key, + required String label, + required double height, + String? nextRoute, + SheetAnchor initialPosition = const SheetAnchor.proportional(1), + SheetAnchor minPosition = const SheetAnchor.proportional(1), + Duration transitionDuration = const Duration(milliseconds: 300), + SheetPhysics? physics, + }) { + return DraggableNavigationSheetRoute( + physics: physics, + initialPosition: initialPosition, + minPosition: minPosition, + transitionDuration: transitionDuration, + builder: (context) => _TestDraggablePageWidget( + key: key, + height: height, + label: label, + onTapBack: () => Navigator.pop(context), + onTapNext: nextRoute != null + ? () => Navigator.pushNamed(context, nextRoute) + : null, + ), + ); + } +} + +class _TestScrollablePageWidget extends StatelessWidget { + const _TestScrollablePageWidget({ + super.key, + required this.height, + required this.label, + required this.itemCount, + this.onTapItem, + this.onTapNext, + this.onTapBack, + }); + + final double? height; + final String label; + final int itemCount; + final void Function(int index)? onTapItem; + final VoidCallback? onTapNext; + final VoidCallback? onTapBack; + + @override + Widget build(BuildContext context) { + return Material( + child: SizedBox( + width: double.infinity, + height: height, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton(onPressed: onTapBack, child: const Text('Back')), + Text(label), + TextButton(onPressed: onTapNext, child: const Text('Next')), + ], + ), + Expanded( + child: ListView( + children: List.generate( + itemCount, + (index) => ListTile( + title: Text('List Item $index'), + onTap: onTapItem != null ? () => onTapItem!(index) : null, + ), + ), + ), + ), + ], + ), + ), + ); + } + + static Route createRoute({ + required String label, + double? height, + int itemCount = 30, + String? nextRoute, + SheetAnchor initialPosition = const SheetAnchor.proportional(1), + SheetAnchor minPosition = const SheetAnchor.proportional(1), + Duration transitionDuration = const Duration(milliseconds: 300), + SheetPhysics? physics, + void Function(int index)? onTapItem, + }) { + return ScrollableNavigationSheetRoute( + physics: physics, + initialPosition: initialPosition, + minPosition: minPosition, + transitionDuration: transitionDuration, + builder: (context) => _TestScrollablePageWidget( + key: ValueKey(label), + height: height, + label: label, + itemCount: itemCount, + onTapItem: onTapItem, + onTapBack: () => Navigator.pop(context), + onTapNext: nextRoute != null + ? () => Navigator.pushNamed(context, nextRoute) + : null, + ), + ); + } +} + +void main() { + late NavigationSheetTransitionObserver transitionObserver; + + setUp(() { + transitionObserver = NavigationSheetTransitionObserver(); + }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/151 + testWidgets( + 'Attached controller emits correct pixel values when dragging', + (tester) async { + final pixelTracking = []; + final controller = SheetController(); + controller.addListener(() { + pixelTracking.add(controller.metrics.maybePixels); + }); + + await tester.pumpWidget( + _TestWidget( + transitionObserver, + sheetController: controller, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + key: const Key('First'), + label: 'First', + height: 300, + minPosition: const SheetAnchor.pixels(0), + // Disable the snapping effect. + physics: const ClampingSheetPhysics(), + ), + }, + ), + ); + // Initial pixel value is emitted after the first build. + expect(pixelTracking, equals([300])); + + // Drag the sheet down by 50 pixels. + await tester.drag( + find.byKey(const Key('First')), + const Offset(0, 50), + // The drag will be broken into two separate calls. + touchSlopY: 20, + ); + await tester.pumpAndSettle(); + expect(pixelTracking, equals([300, 280, 250])); + }, + ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/163 + testWidgets( + 'Attached controller emits correct boundary values', + (tester) async { + final controller = SheetController(); + + (double?, double?)? lastBoundaryValues; // (minPixels, maxPixels) + controller.addListener(() { + lastBoundaryValues = ( + controller.metrics.maybeMinPixels, + controller.metrics.maybeMaxPixels, + ); + }); + + await tester.pumpWidget( + _TestWidget( + transitionObserver, + sheetController: controller, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + key: const Key('First'), + label: 'First', + nextRoute: 'second', + height: 300, + minPosition: const SheetAnchor.proportional(1), + ), + 'second': () => _TestDraggablePageWidget.createRoute( + key: const Key('Second'), + label: 'Second', + height: 500, + minPosition: const SheetAnchor.pixels(200), + transitionDuration: const Duration(milliseconds: 300), + ), + }, + ), + ); + // Initial boundary values are emitted after the first build. + expect(lastBoundaryValues, equals((300, 300))); + + // Dragging the sheet should not change the boundary constraints. + await tester.drag( + find.byKey(const Key('First')), + const Offset(0, 20), + ); + await tester.pumpAndSettle(); + expect(lastBoundaryValues, equals((300, 300))); + + // The controller still emits the boundary values of the first page + // during a route transition. + await tester.tap(find.text('Next')); + // Forwards the transition animation by half. + await tester.pump(const Duration(milliseconds: 150)); + expect(lastBoundaryValues, equals((300, 300))); + // Wait for the transition to finish. + await tester.pumpAndSettle(); + expect(lastBoundaryValues, equals((300, 300))); + + // The controller emits the boundary values of the second page + // after the transition is finished. + await tester.drag( + find.byKey(const Key('Second')), + const Offset(0, 20), + ); + await tester.pumpAndSettle(); + expect(lastBoundaryValues, equals((200, 500))); + }, + ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/168 + testWidgets('Inherited controller should be attached', (tester) async { + final controller = SheetController(); + await tester.pumpWidget( + SheetControllerScope( + controller: controller, + child: _TestWidget( + transitionObserver, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + key: const Key('First'), + label: 'First', + height: 300, + minPosition: const SheetAnchor.pixels(0), + physics: const ClampingSheetPhysics(), + ), + }, + ), + ), + ); + + expect(controller.hasClient, isTrue, + reason: 'The controller should have a client.'); + }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/139 + testWidgets( + 'Works with DropdownButton without crashing', + (tester) async { + String? selectedOption = 'Option 1'; + final routeWithDropdownButton = DraggableNavigationSheetRoute( + builder: (context) { + return Scaffold( + body: Center( + child: StatefulBuilder( + builder: (_, setState) { + return DropdownButton( + value: selectedOption, + menuMaxHeight: 150, + // Ensure all the items are visible at once. + itemHeight: 50, + onChanged: (newValue) => + setState(() => selectedOption = newValue), + items: [ + for (final option in const [ + 'Option 1', + 'Option 2', + 'Option 3', + ]) + DropdownMenuItem( + value: option, + child: Text(option), + ), + ], + ); + }, + ), + ), + ); + }, + ); + + await tester.pumpWidget( + _TestWidget( + transitionObserver, + initialRoute: 'first', + useMaterialApp: true, + routes: {'first': () => routeWithDropdownButton}, + ), + ); + + // 'Option 1' is selected at first. + expect(find.text('Option 1'), findsOneWidget); + + // Tapping 'Option 1' should display a popup menu. + await tester.tap(find.text('Option 1')); + await tester.pumpAndSettle(); + // There are two 'Option 1' texts at this point: + // one in the dropdown button and the other in the popup menu. + expect(find.text('Option 1'), findsNWidgets(2)); + expect(find.text('Option 2'), findsOneWidget); + expect(find.text('Option 3'), findsOneWidget); + + // Selecting 'Option 2' should close the popup menu, + // and 'Option 2' should be displayed in the dropdown button. + await tester.tap(find.text('Option 2')); + await tester.pumpAndSettle(); + expect(find.text('Option 1'), findsNothing); + expect(find.text('Option 2'), findsOneWidget); + expect(find.text('Option 3'), findsNothing); + }, + ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets( + 'Opening keyboard does not interrupts sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestWidget( + transitionObserver, + sheetController: controller, + sheetKey: sheetKey, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + label: 'First', + height: 500, + minPosition: const SheetAnchor.pixels(200), + initialPosition: const SheetAnchor.pixels(200), + ), + }, + contentBuilder: (context, child) { + return KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: child, + ); + }, + ), + ); + + expect(controller.metrics.pixels, 200, + reason: 'The sheet should be at the initial position.'); + expect( + controller.metrics.minPixels < controller.metrics.maxPixels, + isTrue, + reason: 'The sheet should be draggable.', + ); + + // Start animating the sheet to the max position. + unawaited( + controller.animateTo( + const SheetAnchor.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the sheet is animating. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.metrics.pixels, + controller.metrics.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }, + ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/166 + testWidgets( + 'Drag gestures should be ignored during a page transition', + (tester) async { + await tester.pumpWidget( + _TestWidget( + transitionObserver, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + label: 'First', + nextRoute: 'second', + height: 500, + ), + 'second': () => _TestScrollablePageWidget.createRoute( + label: 'Second', + height: double.infinity, + // Intentionally slow down the transition animation. + transitionDuration: const Duration(milliseconds: 600), + ), + }, + ), + ); + + expect(find.text('First').hitTestable(), findsOneWidget); + expect(find.text('Second'), findsNothing); + + // Go to the second page. + await tester.tap(find.text('Next')); + // Forwards the transition animation by half. + await tester.pump(const Duration(milliseconds: 300)); + // Press and hold the list view in the second page + // until the transition animation is finished. + // TODO: Change warnIfMissed to 'true' and verify that + // the long press gesture fails. + await tester.press(find.byKey(const Key('Second')), warnIfMissed: false); + await tester.pumpAndSettle(); + // Ensure that the transition is completed without any exceptions. + expect(find.text('First').hitTestable(), findsNothing); + expect(find.text('Second').hitTestable(), findsOneWidget); + }, + ); + + group('SheetKeyboardDismissible', () { + late FocusNode focusNode; + late ScrollController scrollController; + late Widget testWidget; + + setUp(() { + focusNode = FocusNode(); + scrollController = ScrollController(); + final routeWithTextField = DraggableNavigationSheetRoute( + builder: (context) { + return Material( + child: TextField( + focusNode: focusNode, + scrollController: scrollController, + maxLines: 2, + ), + ); + }, + ); + + testWidget = _TestWidget( + transitionObserver, + initialRoute: 'first', + useMaterialApp: true, + routes: {'first': () => routeWithTextField}, + sheetBuilder: (_, sheet) { + return SheetKeyboardDismissible( + dismissBehavior: const SheetKeyboardDismissBehavior.onDrag( + isContentScrollAware: true, + ), + child: sheet, + ); + }, + ); + }); + + tearDown(() { + focusNode.dispose(); + scrollController.dispose(); + }); + + testWidgets('should dismiss the keyboard when dragging', (tester) async { + await tester.pumpWidget(testWidget); + + final textField = find.byType(TextField); + await tester.showKeyboard(textField); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + + await tester.drag(textField, const Offset(0, -40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Downward dragging should dismiss the keyboard.'); + }); + + testWidgets('should dismiss the keyboard when scrolling', (tester) async { + await tester.pumpWidget(testWidget); + + final textField = find.byType(TextField); + await tester.enterText(textField, 'Hello, world! ' * 100); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + expect(scrollController.position.extentBefore, greaterThan(0), + reason: 'The text field should be able to scroll downwards.'); + + await tester.drag(textField, const Offset(0, 40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Downward dragging should dismiss the keyboard.'); + }); + }); +} diff --git a/test/scrollable/scrollable_sheet_test.dart b/test/scrollable/scrollable_sheet_test.dart new file mode 100644 index 00000000..e4715028 --- /dev/null +++ b/test/scrollable/scrollable_sheet_test.dart @@ -0,0 +1,407 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; +import 'package:smooth_sheets/src/foundation/sheet_position_scope.dart'; +import 'package:smooth_sheets/src/scrollable/scrollable_sheet_position.dart'; +import 'package:smooth_sheets/src/scrollable/sheet_content_scroll_position.dart'; + +import '../src/keyboard_inset_simulation.dart'; + +class _TestApp extends StatelessWidget { + const _TestApp({ + this.useMaterial = false, + required this.child, + }); + + final bool useMaterial; + final Widget child; + + @override + Widget build(BuildContext context) { + if (useMaterial) { + return MaterialApp( + home: child, + ); + } else { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: child, + ), + ); + } + } +} + +class _TestSheetContent extends StatelessWidget { + const _TestSheetContent({ + super.key, + this.height = 500, + this.itemCount = 30, + // Disable the snapping effect by default in tests. + this.onTapItem, + }); + + final double? height; + final int itemCount; + final void Function(int index)? onTapItem; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Material( + color: Colors.white, + child: ListView( + physics: const ClampingScrollPhysics(), + children: List.generate( + itemCount, + (index) => ListTile( + title: Text('Item $index'), + onTap: onTapItem != null ? () => onTapItem!(index) : null, + ), + ), + ), + ), + ); + } +} + +void main() { + testWidgets('Inherited controller should be attached', (tester) async { + final controller = SheetController(); + await tester.pumpWidget( + SheetControllerScope( + controller: controller, + child: const _TestApp( + child: ScrollableSheet( + child: _TestSheetContent(), + ), + ), + ), + ); + + expect(controller.hasClient, isTrue, + reason: 'The controller should have a client.'); + }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets('Opening keyboard does not interrupt sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestApp( + useMaterial: true, + child: KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: ScrollableSheet( + key: sheetKey, + controller: controller, + minPosition: const SheetAnchor.pixels(200), + initialPosition: const SheetAnchor.pixels(200), + child: const _TestSheetContent(height: 500), + ), + ), + ), + ); + + expect(controller.metrics.pixels, 200, + reason: 'The sheet should be at the initial position.'); + expect(controller.metrics.minPixels < controller.metrics.maxPixels, isTrue, + reason: 'The sheet should be draggable.'); + + // Start animating the sheet to the max position. + unawaited( + controller.animateTo( + const SheetAnchor.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the sheet is animating. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.metrics.pixels, + controller.metrics.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }); + + group('Press-and-hold gesture should stop momentum scrolling', () { + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/190 + testWidgets( + 'in a plain ListView', + (tester) async { + const targetKey = Key('Target'); + final controller = SheetController(); + late ScrollController scrollController; + + await tester.pumpWidget( + _TestApp( + child: ScrollableSheet( + controller: controller, + child: Builder( + builder: (context) { + // TODO(fujita): Refactor this line after #116 is resolved. + scrollController = PrimaryScrollController.of(context); + return _TestSheetContent( + key: targetKey, + itemCount: 1000, + height: null, + // The items need to be clickable to cause the issue. + onTapItem: (index) {}, + ); + }, + ), + ), + ), + ); + + const dragDistance = 200.0; + const flingSpeed = 2000.0; + await tester.fling( + find.byKey(targetKey), + const Offset(0, -1 * dragDistance), // Fling up + flingSpeed, + ); + + final offsetAfterFling = scrollController.offset; + // Don't know why, but we need to call `pump` at least 2 times + // to forward the animation clock. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + final offsetBeforePress = scrollController.offset; + expect(offsetBeforePress, greaterThan(offsetAfterFling), + reason: 'Momentum scrolling should be in progress.'); + + // Press and hold the finger on the target widget. + await tester.press(find.byKey(targetKey)); + // Wait for the momentum scrolling to stop. + await tester.pumpAndSettle(); + final offsetAfterPress = scrollController.offset; + expect( + offsetAfterPress, + equals(offsetBeforePress), + reason: 'Momentum scrolling should be stopped immediately ' + 'by pressing and holding.', + ); + }, + ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/214 + testWidgets('in a PageView with multiple ListViews', (tester) async { + late final ScrollController scrollController; + + await tester.pumpWidget( + _TestApp( + child: ScrollableSheet( + child: Builder( + builder: (context) { + // TODO(fujita): Refactor this line after #116 is resolved. + scrollController = PrimaryScrollController.of(context); + return Material( + child: PageView( + controller: PageController(), + children: [ + for (var i = 0; i < 2; i++) + ListView.builder( + key: Key('ListView #$i'), + itemCount: 100, + itemBuilder: (context, index) { + return ListTile( + onTap: () {}, + title: Text('Item $index'), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + + const listViewKey = Key('ListView #0'); + const dragDistance = 200.0; + const flingSpeed = 2000.0; + await tester.fling( + find.byKey(listViewKey), + const Offset(0, -1 * dragDistance), // Fling up + flingSpeed, + ); + + final offsetAfterFling = scrollController.offset; + // Don't know why, but we need to call `pump` at least 2 times + // to forward the animation clock. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + final offsetBeforePress = scrollController.offset; + expect(offsetBeforePress, greaterThan(offsetAfterFling), + reason: 'Momentum scrolling should be in progress.'); + + // Press and hold the finger on the target widget. + await tester.press(find.byKey(listViewKey)); + // Wait for the momentum scrolling to stop. + await tester.pumpAndSettle(); + final offsetAfterPress = scrollController.offset; + expect( + offsetAfterPress, + equals(offsetBeforePress), + reason: 'Momentum scrolling should be stopped immediately ' + 'by pressing and holding.', + ); + }); + }); + + group('SheetKeyboardDismissible', () { + late FocusNode focusNode; + late Widget testWidget; + + setUp(() { + focusNode = FocusNode(); + testWidget = _TestApp( + useMaterial: true, + child: SheetKeyboardDismissible( + dismissBehavior: const SheetKeyboardDismissBehavior.onDrag( + isContentScrollAware: true, + ), + child: ScrollableSheet( + child: Material( + child: Column( + children: [ + TextField(focusNode: focusNode), + Expanded( + child: ListView( + children: List.generate( + 30, + (index) => ListTile( + title: Text('Item $index'), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + + tearDown(() { + focusNode.dispose(); + }); + + testWidgets('should dismiss the keyboard when dragging', (tester) async { + await tester.pumpWidget(testWidget); + + await tester.showKeyboard(find.byType(TextField)); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + + await tester.drag(find.byType(ListView), const Offset(0, 40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Downward dragging should dismiss the keyboard.'); + }); + + testWidgets('should dismiss the keyboard when scrolling', (tester) async { + await tester.pumpWidget(testWidget); + + final textField = find.byType(TextField).first; + await tester.showKeyboard(textField); + expect(focusNode.hasFocus, isTrue, + reason: 'The keyboard should be shown.'); + + await tester.drag(find.byType(ListView), const Offset(0, -40)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse, + reason: 'Upward scrolling should dismiss the keyboard.'); + }); + }); + + // Regression tests for: + // - https://github.com/fujidaiti/smooth_sheets/issues/207 + // - https://github.com/fujidaiti/smooth_sheets/issues/212 + group('Infinite ballistic scroll activity test', () { + late ScrollController scrollController; + late ScrollableSheetPosition sheetPosition; + late Widget testWidget; + + setUp(() { + testWidget = ScrollableSheet( + child: Builder( + builder: (context) { + scrollController = PrimaryScrollController.of(context); + sheetPosition = SheetPositionScope.of(context); + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Container( + color: Colors.white, + width: double.infinity, + height: 1200, + ), + ); + }, + ), + ); + }); + + testWidgets('top edge', (tester) async { + await tester.pumpWidget(testWidget); + await tester.pumpAndSettle(); + expect(scrollController.position.pixels, 0); + + // Start a ballistic animation from a position extremely close to, + // but not equal, to the initial position. + scrollController.position.correctPixels(-0.000000001); + sheetPosition.goBallisticWithScrollPosition( + velocity: 0, + scrollPosition: scrollController.position as SheetContentScrollPosition, + ); + await tester.pumpAndSettle(); + expect(scrollController.position.pixels, 0); + expect(sheetPosition.activity, isA(), + reason: 'Should not enter an infinite recursion ' + 'of BallisticScrollDrivenSheetActivity'); + }); + + testWidgets('bottom edge', (tester) async { + await tester.pumpWidget(testWidget); + scrollController.jumpTo(600.0); + await tester.pumpAndSettle(); + expect(scrollController.position.extentAfter, 0, + reason: 'Ensure that the scroll view cannot be scrolled anymore'); + + // Start a ballistic animation from a position extremely close to, + // but not equal, to the current position. + scrollController.position.correctPixels(600.000000001); + sheetPosition.goBallisticWithScrollPosition( + velocity: 0, + scrollPosition: scrollController.position as SheetContentScrollPosition, + ); + await tester.pumpAndSettle(); + expect(scrollController.position.pixels, 600.0); + expect(sheetPosition.activity, isA(), + reason: 'Should not enter an infinite recursion ' + 'of BallisticScrollDrivenSheetActivity'); + }); + }); +} diff --git a/test/src/keyboard_inset_simulation.dart b/test/src/keyboard_inset_simulation.dart new file mode 100644 index 00000000..ccbbf286 --- /dev/null +++ b/test/src/keyboard_inset_simulation.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A widget that simulates [MediaQueryData.viewInsets] as if the keyboard +/// is shown. +/// +/// This exposes a [MediaQueryData] to its descendants, and if +/// [KeyboardInsetSimulationState.showKeyboard] is called once, +/// it will gradually increase the `MediaQueryData.viewInsets.bottom` +/// to the given [keyboardHeight] as if the on-screen keyboard is appearing. +/// +/// Although there is [WidgetTester.showKeyboard] method, we use this widget +/// instead to simulate the keyboard appearance, as `showKeyboard` does not +/// change the `viewInsets` value. +class KeyboardInsetSimulation extends StatefulWidget { + const KeyboardInsetSimulation({ + super.key, + required this.keyboardHeight, + required this.child, + }); + + final double keyboardHeight; + final Widget child; + + @override + State createState() => + KeyboardInsetSimulationState(); +} + +class KeyboardInsetSimulationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + Future showKeyboard(Duration duration) async { + assert(_controller.isDismissed); + return _controller.animateTo(widget.keyboardHeight, duration: duration); + } + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + lowerBound: 0, + upperBound: widget.keyboardHeight, + )..addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(KeyboardInsetSimulation oldWidget) { + super.didUpdateWidget(oldWidget); + assert(widget.keyboardHeight == oldWidget.keyboardHeight); + } + + @override + Widget build(BuildContext context) { + final inheritedMediaQuery = MediaQuery.of(context); + return MediaQuery( + data: inheritedMediaQuery.copyWith( + viewInsets: inheritedMediaQuery.viewInsets.copyWith( + bottom: _controller.value, + ), + ), + child: widget.child, + ); + } +} diff --git a/test/src/matchers.dart b/test/src/matchers.dart new file mode 100644 index 00000000..bbc5a4a4 --- /dev/null +++ b/test/src/matchers.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; + +Matcher throwsError({required String name}) => throwsA( + isA().having( + (e) => e.runtimeType.toString(), + 'runtimeType', + name, + ), + ); + +/// A matcher that checks if the error is a LateError. +/// +/// This is useful for verifying that a late field has not been initialized. +Matcher get isNotInitialized => throwsError(name: 'LateError'); diff --git a/test/src/stubbing.dart b/test/src/stubbing.dart new file mode 100644 index 00000000..f3f66da2 --- /dev/null +++ b/test/src/stubbing.dart @@ -0,0 +1,161 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:smooth_sheets/src/foundation/foundation.dart'; +import 'package:smooth_sheets/src/foundation/sheet_context.dart'; +import 'package:smooth_sheets/src/foundation/sheet_position.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec() +]) +import 'stubbing.mocks.dart'; + +export 'stubbing.mocks.dart'; + +class MutableSheetMetrics with SheetMetrics { + MutableSheetMetrics({ + required this.maybePixels, + required this.maybeMinPosition, + required this.maybeMaxPosition, + required this.maybeContentSize, + required this.maybeViewportSize, + required this.maybeViewportInsets, + required this.devicePixelRatio, + }); + + @override + double devicePixelRatio; + + @override + SheetAnchor? maybeMaxPosition; + + @override + SheetAnchor? maybeMinPosition; + + @override + double? maybePixels; + + @override + Size? maybeContentSize; + + @override + Size? maybeViewportSize; + + @override + EdgeInsets? maybeViewportInsets; + + @override + SheetMetrics copyWith({ + double? pixels, + SheetAnchor? minPosition, + SheetAnchor? maxPosition, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + double? devicePixelRatio, + }) { + return SheetMetricsSnapshot( + pixels: pixels ?? maybePixels, + minPosition: minPosition ?? maybeMinPosition, + maxPosition: maxPosition ?? maybeMaxPosition, + contentSize: contentSize ?? maybeContentSize, + viewportSize: viewportSize ?? maybeViewportSize, + viewportInsets: viewportInsets ?? maybeViewportInsets, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +(MutableSheetMetrics, MockSheetPosition) createMockSheetPosition({ + required double pixels, + required SheetAnchor minPosition, + required SheetAnchor maxPosition, + required Size contentSize, + required Size viewportSize, + required EdgeInsets viewportInsets, + required double devicePixelRatio, + SheetPhysics? physics, +}) { + final metricsRegistry = MutableSheetMetrics( + maybePixels: pixels, + maybeMinPosition: minPosition, + maybeMaxPosition: maxPosition, + maybeContentSize: contentSize, + maybeViewportSize: viewportSize, + maybeViewportInsets: viewportInsets, + devicePixelRatio: devicePixelRatio, + ); + + final position = MockSheetPosition(); + when(position.pixels).thenAnswer((_) => metricsRegistry.pixels); + when(position.maybePixels).thenAnswer((_) => metricsRegistry.maybePixels); + when(position.minPosition).thenAnswer((_) => metricsRegistry.minPosition); + when(position.maybeMinPosition) + .thenAnswer((_) => metricsRegistry.maybeMinPosition); + when(position.maxPosition).thenAnswer((_) => metricsRegistry.maxPosition); + when(position.maybeMaxPosition) + .thenAnswer((_) => metricsRegistry.maybeMaxPosition); + when(position.contentSize).thenAnswer((_) => metricsRegistry.contentSize); + when(position.maybeContentSize) + .thenAnswer((_) => metricsRegistry.maybeContentSize); + when(position.viewportSize).thenAnswer((_) => metricsRegistry.viewportSize); + when(position.maybeViewportSize) + .thenAnswer((_) => metricsRegistry.maybeViewportSize); + when(position.viewportInsets) + .thenAnswer((_) => metricsRegistry.viewportInsets); + when(position.maybeViewportInsets) + .thenAnswer((_) => metricsRegistry.maybeViewportInsets); + when(position.devicePixelRatio) + .thenAnswer((_) => metricsRegistry.devicePixelRatio); + when(position.snapshot).thenAnswer((_) => metricsRegistry); + + when(position.setPixels(any)).thenAnswer((invocation) { + metricsRegistry.maybePixels = + invocation.positionalArguments.first as double; + }); + when(position.applyNewContentSize(any)).thenAnswer((invocation) { + metricsRegistry.maybeContentSize = + invocation.positionalArguments.first as Size; + }); + when(position.applyNewViewportDimensions(any, any)).thenAnswer((invocation) { + metricsRegistry + ..maybeViewportSize = invocation.positionalArguments.first as Size + ..maybeViewportInsets = invocation.positionalArguments.last as EdgeInsets; + }); + when(position.applyNewBoundaryConstraints(any, any)).thenAnswer((invocation) { + metricsRegistry + ..maybeMinPosition = invocation.positionalArguments.first as SheetAnchor + ..maybeMaxPosition = invocation.positionalArguments.last as SheetAnchor; + }); + when(position.copyWith( + pixels: anyNamed('pixels'), + minPosition: anyNamed('minPosition'), + maxPosition: anyNamed('maxPosition'), + contentSize: anyNamed('contentSize'), + viewportSize: anyNamed('viewportSize'), + viewportInsets: anyNamed('viewportInsets'), + devicePixelRatio: anyNamed('devicePixelRatio'), + )).thenAnswer((invocation) { + return metricsRegistry.copyWith( + pixels: invocation.namedArguments[#pixels] as double?, + minPosition: invocation.namedArguments[#minPosition] as SheetAnchor?, + maxPosition: invocation.namedArguments[#maxPosition] as SheetAnchor?, + contentSize: invocation.namedArguments[#contentSize] as Size?, + viewportSize: invocation.namedArguments[#viewportSize] as Size?, + viewportInsets: invocation.namedArguments[#viewportInsets] as EdgeInsets?, + devicePixelRatio: invocation.namedArguments[#devicePixelRatio] as double?, + ); + }); + + if (physics != null) { + when(position.physics).thenReturn(physics); + } + + return (metricsRegistry, position); +} diff --git a/test/src/stubbing.mocks.dart b/test/src/stubbing.mocks.dart new file mode 100644 index 00000000..39b473d8 --- /dev/null +++ b/test/src/stubbing.mocks.dart @@ -0,0 +1,1613 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in smooth_sheets/test/src/stubbing.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i9; +import 'dart:ui' as _i6; + +import 'package:flutter/cupertino.dart' as _i7; +import 'package:flutter/foundation.dart' as _i10; +import 'package:flutter/gestures.dart' as _i8; +import 'package:flutter/scheduler.dart' as _i11; +import 'package:flutter/src/animation/curves.dart' as _i15; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i16; +import 'package:smooth_sheets/src/foundation/sheet_activity.dart' as _i5; +import 'package:smooth_sheets/src/foundation/sheet_context.dart' as _i2; +import 'package:smooth_sheets/src/foundation/sheet_drag.dart' as _i12; +import 'package:smooth_sheets/src/foundation/sheet_gesture_tamperer.dart' + as _i14; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart' as _i3; +import 'package:smooth_sheets/src/foundation/sheet_position.dart' as _i4; +import 'package:smooth_sheets/src/foundation/sheet_status.dart' as _i13; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSheetContext_0 extends _i1.SmartFake implements _i2.SheetContext { + _FakeSheetContext_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSheetPhysics_1 extends _i1.SmartFake implements _i3.SheetPhysics { + _FakeSheetPhysics_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSheetActivity_2 extends _i1.SmartFake + implements _i5.SheetActivity { + _FakeSheetActivity_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSheetMetrics_3 extends _i1.SmartFake implements _i4.SheetMetrics { + _FakeSheetMetrics_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSheetAnchor_4 extends _i1.SmartFake implements _i4.SheetAnchor { + _FakeSheetAnchor_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSize_5 extends _i1.SmartFake implements _i6.Size { + _FakeSize_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeEdgeInsets_6 extends _i1.SmartFake implements _i7.EdgeInsets { + _FakeEdgeInsets_6( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDrag_7 extends _i1.SmartFake implements _i8.Drag { + _FakeDrag_7( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeTickerProvider_8 extends _i1.SmartFake + implements _i7.TickerProvider { + _FakeTickerProvider_8( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAnimation_9 extends _i1.SmartFake implements _i7.Animation { + _FakeAnimation_9( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeTickerFuture_10 extends _i1.SmartFake implements _i7.TickerFuture { + _FakeTickerFuture_10( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFuture_11 extends _i1.SmartFake implements _i9.Future { + _FakeFuture_11( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDiagnosticsNode_12 extends _i1.SmartFake + implements _i7.DiagnosticsNode { + _FakeDiagnosticsNode_12( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({ + _i10.TextTreeConfiguration? parentConfiguration, + _i7.DiagnosticLevel? minLevel = _i7.DiagnosticLevel.info, + }) => + super.toString(); +} + +class _FakeTicker_13 extends _i1.SmartFake implements _i11.Ticker { + _FakeTicker_13( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({bool? debugIncludeStack = false}) => super.toString(); +} + +/// A class which mocks [SheetPosition]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSheetPosition extends _i1.Mock implements _i4.SheetPosition { + @override + _i2.SheetContext get context => (super.noSuchMethod( + Invocation.getter(#context), + returnValue: _FakeSheetContext_0( + this, + Invocation.getter(#context), + ), + returnValueForMissingStub: _FakeSheetContext_0( + this, + Invocation.getter(#context), + ), + ) as _i2.SheetContext); + + @override + set currentDrag(_i12.SheetDragController? _currentDrag) => super.noSuchMethod( + Invocation.setter( + #currentDrag, + _currentDrag, + ), + returnValueForMissingStub: null, + ); + + @override + double get devicePixelRatio => (super.noSuchMethod( + Invocation.getter(#devicePixelRatio), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + _i13.SheetStatus get status => (super.noSuchMethod( + Invocation.getter(#status), + returnValue: _i13.SheetStatus.stable, + returnValueForMissingStub: _i13.SheetStatus.stable, + ) as _i13.SheetStatus); + + @override + _i3.SheetPhysics get physics => (super.noSuchMethod( + Invocation.getter(#physics), + returnValue: _FakeSheetPhysics_1( + this, + Invocation.getter(#physics), + ), + returnValueForMissingStub: _FakeSheetPhysics_1( + this, + Invocation.getter(#physics), + ), + ) as _i3.SheetPhysics); + + @override + _i5.SheetActivity<_i4.SheetPosition> get activity => (super.noSuchMethod( + Invocation.getter(#activity), + returnValue: _FakeSheetActivity_2<_i4.SheetPosition>( + this, + Invocation.getter(#activity), + ), + returnValueForMissingStub: _FakeSheetActivity_2<_i4.SheetPosition>( + this, + Invocation.getter(#activity), + ), + ) as _i5.SheetActivity<_i4.SheetPosition>); + + @override + _i4.SheetMetrics get snapshot => (super.noSuchMethod( + Invocation.getter(#snapshot), + returnValue: _FakeSheetMetrics_3( + this, + Invocation.getter(#snapshot), + ), + returnValueForMissingStub: _FakeSheetMetrics_3( + this, + Invocation.getter(#snapshot), + ), + ) as _i4.SheetMetrics); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + double get pixels => (super.noSuchMethod( + Invocation.getter(#pixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + double get minPixels => (super.noSuchMethod( + Invocation.getter(#minPixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + double get maxPixels => (super.noSuchMethod( + Invocation.getter(#maxPixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + _i4.SheetAnchor get minPosition => (super.noSuchMethod( + Invocation.getter(#minPosition), + returnValue: _FakeSheetAnchor_4( + this, + Invocation.getter(#minPosition), + ), + returnValueForMissingStub: _FakeSheetAnchor_4( + this, + Invocation.getter(#minPosition), + ), + ) as _i4.SheetAnchor); + + @override + _i4.SheetAnchor get maxPosition => (super.noSuchMethod( + Invocation.getter(#maxPosition), + returnValue: _FakeSheetAnchor_4( + this, + Invocation.getter(#maxPosition), + ), + returnValueForMissingStub: _FakeSheetAnchor_4( + this, + Invocation.getter(#maxPosition), + ), + ) as _i4.SheetAnchor); + + @override + _i6.Size get contentSize => (super.noSuchMethod( + Invocation.getter(#contentSize), + returnValue: _FakeSize_5( + this, + Invocation.getter(#contentSize), + ), + returnValueForMissingStub: _FakeSize_5( + this, + Invocation.getter(#contentSize), + ), + ) as _i6.Size); + + @override + _i6.Size get viewportSize => (super.noSuchMethod( + Invocation.getter(#viewportSize), + returnValue: _FakeSize_5( + this, + Invocation.getter(#viewportSize), + ), + returnValueForMissingStub: _FakeSize_5( + this, + Invocation.getter(#viewportSize), + ), + ) as _i6.Size); + + @override + _i7.EdgeInsets get viewportInsets => (super.noSuchMethod( + Invocation.getter(#viewportInsets), + returnValue: _FakeEdgeInsets_6( + this, + Invocation.getter(#viewportInsets), + ), + returnValueForMissingStub: _FakeEdgeInsets_6( + this, + Invocation.getter(#viewportInsets), + ), + ) as _i7.EdgeInsets); + + @override + double get viewPixels => (super.noSuchMethod( + Invocation.getter(#viewPixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + double get minViewPixels => (super.noSuchMethod( + Invocation.getter(#minViewPixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + double get maxViewPixels => (super.noSuchMethod( + Invocation.getter(#maxViewPixels), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + bool get hasDimensions => (super.noSuchMethod( + Invocation.getter(#hasDimensions), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isPixelsInBounds => (super.noSuchMethod( + Invocation.getter(#isPixelsInBounds), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isPixelsOutOfBounds => (super.noSuchMethod( + Invocation.getter(#isPixelsOutOfBounds), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + void takeOver(_i4.SheetPosition? other) => super.noSuchMethod( + Invocation.method( + #takeOver, + [other], + ), + returnValueForMissingStub: null, + ); + + @override + void updateGestureTamperer(_i14.SheetGestureProxyMixin? gestureTamperer) => + super.noSuchMethod( + Invocation.method( + #updateGestureTamperer, + [gestureTamperer], + ), + returnValueForMissingStub: null, + ); + + @override + void updatePhysics(_i3.SheetPhysics? physics) => super.noSuchMethod( + Invocation.method( + #updatePhysics, + [physics], + ), + returnValueForMissingStub: null, + ); + + @override + void applyNewContentSize(_i6.Size? contentSize) => super.noSuchMethod( + Invocation.method( + #applyNewContentSize, + [contentSize], + ), + returnValueForMissingStub: null, + ); + + @override + void applyNewViewportDimensions( + _i6.Size? size, + _i7.EdgeInsets? insets, + ) => + super.noSuchMethod( + Invocation.method( + #applyNewViewportDimensions, + [ + size, + insets, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void applyNewBoundaryConstraints( + _i4.SheetAnchor? minPosition, + _i4.SheetAnchor? maxPosition, + ) => + super.noSuchMethod( + Invocation.method( + #applyNewBoundaryConstraints, + [ + minPosition, + maxPosition, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void markAsDimensionsWillChange() => super.noSuchMethod( + Invocation.method( + #markAsDimensionsWillChange, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void markAsDimensionsChanged() => super.noSuchMethod( + Invocation.method( + #markAsDimensionsChanged, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void onDimensionsFinalized() => super.noSuchMethod( + Invocation.method( + #onDimensionsFinalized, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void beginActivity(_i5.SheetActivity<_i4.SheetPosition>? activity) => + super.noSuchMethod( + Invocation.method( + #beginActivity, + [activity], + ), + returnValueForMissingStub: null, + ); + + @override + void goIdle() => super.noSuchMethod( + Invocation.method( + #goIdle, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void goBallistic(double? velocity) => super.noSuchMethod( + Invocation.method( + #goBallistic, + [velocity], + ), + returnValueForMissingStub: null, + ); + + @override + void goBallisticWith(_i7.Simulation? simulation) => super.noSuchMethod( + Invocation.method( + #goBallisticWith, + [simulation], + ), + returnValueForMissingStub: null, + ); + + @override + void settleTo( + _i4.SheetAnchor? detent, + Duration? duration, + ) => + super.noSuchMethod( + Invocation.method( + #settleTo, + [ + detent, + duration, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i8.Drag drag( + _i7.DragStartDetails? details, + _i6.VoidCallback? dragCancelCallback, + ) => + (super.noSuchMethod( + Invocation.method( + #drag, + [ + details, + dragCancelCallback, + ], + ), + returnValue: _FakeDrag_7( + this, + Invocation.method( + #drag, + [ + details, + dragCancelCallback, + ], + ), + ), + returnValueForMissingStub: _FakeDrag_7( + this, + Invocation.method( + #drag, + [ + details, + dragCancelCallback, + ], + ), + ), + ) as _i8.Drag); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void setPixels(double? pixels) => super.noSuchMethod( + Invocation.method( + #setPixels, + [pixels], + ), + returnValueForMissingStub: null, + ); + + @override + void correctPixels(double? pixels) => super.noSuchMethod( + Invocation.method( + #correctPixels, + [pixels], + ), + returnValueForMissingStub: null, + ); + + @override + _i9.Future animateTo( + _i4.SheetAnchor? newPosition, { + _i7.Curve? curve = _i7.Curves.easeInOut, + Duration? duration = const Duration(milliseconds: 300), + }) => + (super.noSuchMethod( + Invocation.method( + #animateTo, + [newPosition], + { + #curve: curve, + #duration: duration, + }, + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i4.SheetMetrics copyWith({ + double? pixels, + _i4.SheetAnchor? minPosition, + _i4.SheetAnchor? maxPosition, + _i6.Size? contentSize, + _i6.Size? viewportSize, + _i7.EdgeInsets? viewportInsets, + double? devicePixelRatio, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #pixels: pixels, + #minPosition: minPosition, + #maxPosition: maxPosition, + #contentSize: contentSize, + #viewportSize: viewportSize, + #viewportInsets: viewportInsets, + #devicePixelRatio: devicePixelRatio, + }, + ), + returnValue: _FakeSheetMetrics_3( + this, + Invocation.method( + #copyWith, + [], + { + #pixels: pixels, + #minPosition: minPosition, + #maxPosition: maxPosition, + #contentSize: contentSize, + #viewportSize: viewportSize, + #viewportInsets: viewportInsets, + #devicePixelRatio: devicePixelRatio, + }, + ), + ), + returnValueForMissingStub: _FakeSheetMetrics_3( + this, + Invocation.method( + #copyWith, + [], + { + #pixels: pixels, + #minPosition: minPosition, + #maxPosition: maxPosition, + #contentSize: contentSize, + #viewportSize: viewportSize, + #viewportInsets: viewportInsets, + #devicePixelRatio: devicePixelRatio, + }, + ), + ), + ) as _i4.SheetMetrics); + + @override + void didUpdateMetrics() => super.noSuchMethod( + Invocation.method( + #didUpdateMetrics, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void didDragStart(_i12.SheetDragStartDetails? details) => super.noSuchMethod( + Invocation.method( + #didDragStart, + [details], + ), + returnValueForMissingStub: null, + ); + + @override + void didDragEnd(_i12.SheetDragEndDetails? details) => super.noSuchMethod( + Invocation.method( + #didDragEnd, + [details], + ), + returnValueForMissingStub: null, + ); + + @override + void didDragUpdateMetrics(_i12.SheetDragUpdateDetails? details) => + super.noSuchMethod( + Invocation.method( + #didDragUpdateMetrics, + [details], + ), + returnValueForMissingStub: null, + ); + + @override + void didDragCancel() => super.noSuchMethod( + Invocation.method( + #didDragCancel, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void didOverflowBy(double? overflow) => super.noSuchMethod( + Invocation.method( + #didOverflowBy, + [overflow], + ), + returnValueForMissingStub: null, + ); + + @override + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #removeListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method( + #notifyListeners, + [], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [SheetContext]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSheetContext extends _i1.Mock implements _i2.SheetContext { + @override + _i7.TickerProvider get vsync => (super.noSuchMethod( + Invocation.getter(#vsync), + returnValue: _FakeTickerProvider_8( + this, + Invocation.getter(#vsync), + ), + returnValueForMissingStub: _FakeTickerProvider_8( + this, + Invocation.getter(#vsync), + ), + ) as _i7.TickerProvider); + + @override + double get devicePixelRatio => (super.noSuchMethod( + Invocation.getter(#devicePixelRatio), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); +} + +/// A class which mocks [AnimationController]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAnimationController extends _i1.Mock + implements _i7.AnimationController { + @override + double get lowerBound => (super.noSuchMethod( + Invocation.getter(#lowerBound), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + double get upperBound => (super.noSuchMethod( + Invocation.getter(#upperBound), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + _i7.AnimationBehavior get animationBehavior => (super.noSuchMethod( + Invocation.getter(#animationBehavior), + returnValue: _i7.AnimationBehavior.normal, + returnValueForMissingStub: _i7.AnimationBehavior.normal, + ) as _i7.AnimationBehavior); + + @override + set duration(Duration? _duration) => super.noSuchMethod( + Invocation.setter( + #duration, + _duration, + ), + returnValueForMissingStub: null, + ); + + @override + set reverseDuration(Duration? _reverseDuration) => super.noSuchMethod( + Invocation.setter( + #reverseDuration, + _reverseDuration, + ), + returnValueForMissingStub: null, + ); + + @override + _i7.Animation get view => (super.noSuchMethod( + Invocation.getter(#view), + returnValue: _FakeAnimation_9( + this, + Invocation.getter(#view), + ), + returnValueForMissingStub: _FakeAnimation_9( + this, + Invocation.getter(#view), + ), + ) as _i7.Animation); + + @override + double get value => (super.noSuchMethod( + Invocation.getter(#value), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + set value(double? newValue) => super.noSuchMethod( + Invocation.setter( + #value, + newValue, + ), + returnValueForMissingStub: null, + ); + + @override + double get velocity => (super.noSuchMethod( + Invocation.getter(#velocity), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); + + @override + bool get isAnimating => (super.noSuchMethod( + Invocation.getter(#isAnimating), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i7.AnimationStatus get status => (super.noSuchMethod( + Invocation.getter(#status), + returnValue: _i7.AnimationStatus.dismissed, + returnValueForMissingStub: _i7.AnimationStatus.dismissed, + ) as _i7.AnimationStatus); + + @override + bool get isDismissed => (super.noSuchMethod( + Invocation.getter(#isDismissed), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isCompleted => (super.noSuchMethod( + Invocation.getter(#isCompleted), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + void resync(_i7.TickerProvider? vsync) => super.noSuchMethod( + Invocation.method( + #resync, + [vsync], + ), + returnValueForMissingStub: null, + ); + + @override + void reset() => super.noSuchMethod( + Invocation.method( + #reset, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i7.TickerFuture forward({double? from}) => (super.noSuchMethod( + Invocation.method( + #forward, + [], + {#from: from}, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #forward, + [], + {#from: from}, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #forward, + [], + {#from: from}, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture reverse({double? from}) => (super.noSuchMethod( + Invocation.method( + #reverse, + [], + {#from: from}, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #reverse, + [], + {#from: from}, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #reverse, + [], + {#from: from}, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture animateTo( + double? target, { + Duration? duration, + _i7.Curve? curve = _i15.Curves.linear, + }) => + (super.noSuchMethod( + Invocation.method( + #animateTo, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #animateTo, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #animateTo, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture animateBack( + double? target, { + Duration? duration, + _i7.Curve? curve = _i15.Curves.linear, + }) => + (super.noSuchMethod( + Invocation.method( + #animateBack, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #animateBack, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #animateBack, + [target], + { + #duration: duration, + #curve: curve, + }, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture repeat({ + double? min, + double? max, + bool? reverse = false, + Duration? period, + }) => + (super.noSuchMethod( + Invocation.method( + #repeat, + [], + { + #min: min, + #max: max, + #reverse: reverse, + #period: period, + }, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #repeat, + [], + { + #min: min, + #max: max, + #reverse: reverse, + #period: period, + }, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #repeat, + [], + { + #min: min, + #max: max, + #reverse: reverse, + #period: period, + }, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture fling({ + double? velocity = 1.0, + _i7.SpringDescription? springDescription, + _i7.AnimationBehavior? animationBehavior, + }) => + (super.noSuchMethod( + Invocation.method( + #fling, + [], + { + #velocity: velocity, + #springDescription: springDescription, + #animationBehavior: animationBehavior, + }, + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #fling, + [], + { + #velocity: velocity, + #springDescription: springDescription, + #animationBehavior: animationBehavior, + }, + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #fling, + [], + { + #velocity: velocity, + #springDescription: springDescription, + #animationBehavior: animationBehavior, + }, + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.TickerFuture animateWith(_i7.Simulation? simulation) => + (super.noSuchMethod( + Invocation.method( + #animateWith, + [simulation], + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #animateWith, + [simulation], + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #animateWith, + [simulation], + ), + ), + ) as _i7.TickerFuture); + + @override + void stop({bool? canceled = true}) => super.noSuchMethod( + Invocation.method( + #stop, + [], + {#canceled: canceled}, + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + String toStringDetails() => (super.noSuchMethod( + Invocation.method( + #toStringDetails, + [], + ), + returnValue: _i16.dummyValue( + this, + Invocation.method( + #toStringDetails, + [], + ), + ), + returnValueForMissingStub: _i16.dummyValue( + this, + Invocation.method( + #toStringDetails, + [], + ), + ), + ) as String); + + @override + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #removeListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void addStatusListener(_i7.AnimationStatusListener? listener) => + super.noSuchMethod( + Invocation.method( + #addStatusListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeStatusListener(_i7.AnimationStatusListener? listener) => + super.noSuchMethod( + Invocation.method( + #removeStatusListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + _i7.Animation drive(_i7.Animatable? child) => (super.noSuchMethod( + Invocation.method( + #drive, + [child], + ), + returnValue: _FakeAnimation_9( + this, + Invocation.method( + #drive, + [child], + ), + ), + returnValueForMissingStub: _FakeAnimation_9( + this, + Invocation.method( + #drive, + [child], + ), + ), + ) as _i7.Animation); + + @override + void didRegisterListener() => super.noSuchMethod( + Invocation.method( + #didRegisterListener, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void didUnregisterListener() => super.noSuchMethod( + Invocation.method( + #didUnregisterListener, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void clearListeners() => super.noSuchMethod( + Invocation.method( + #clearListeners, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method( + #notifyListeners, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void clearStatusListeners() => super.noSuchMethod( + Invocation.method( + #clearStatusListeners, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void notifyStatusListeners(_i7.AnimationStatus? status) => super.noSuchMethod( + Invocation.method( + #notifyStatusListeners, + [status], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [TickerFuture]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { + @override + _i9.Future get orCancel => (super.noSuchMethod( + Invocation.getter(#orCancel), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + void whenCompleteOrCancel(_i6.VoidCallback? callback) => super.noSuchMethod( + Invocation.method( + #whenCompleteOrCancel, + [callback], + ), + returnValueForMissingStub: null, + ); + + @override + _i9.Stream asStream() => (super.noSuchMethod( + Invocation.method( + #asStream, + [], + ), + returnValue: _i9.Stream.empty(), + returnValueForMissingStub: _i9.Stream.empty(), + ) as _i9.Stream); + + @override + _i9.Future catchError( + Function? onError, { + bool Function(Object)? test, + }) => + (super.noSuchMethod( + Invocation.method( + #catchError, + [onError], + {#test: test}, + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future then( + _i9.FutureOr Function(void)? onValue, { + Function? onError, + }) => + (super.noSuchMethod( + Invocation.method( + #then, + [onValue], + {#onError: onError}, + ), + returnValue: _i16.ifNotNull( + _i16.dummyValueOrNull( + this, + Invocation.method( + #then, + [onValue], + {#onError: onError}, + ), + ), + (R v) => _i9.Future.value(v), + ) ?? + _FakeFuture_11( + this, + Invocation.method( + #then, + [onValue], + {#onError: onError}, + ), + ), + returnValueForMissingStub: _i16.ifNotNull( + _i16.dummyValueOrNull( + this, + Invocation.method( + #then, + [onValue], + {#onError: onError}, + ), + ), + (R v) => _i9.Future.value(v), + ) ?? + _FakeFuture_11( + this, + Invocation.method( + #then, + [onValue], + {#onError: onError}, + ), + ), + ) as _i9.Future); + + @override + _i9.Future timeout( + Duration? timeLimit, { + _i9.FutureOr Function()? onTimeout, + }) => + (super.noSuchMethod( + Invocation.method( + #timeout, + [timeLimit], + {#onTimeout: onTimeout}, + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future whenComplete(dynamic Function()? action) => + (super.noSuchMethod( + Invocation.method( + #whenComplete, + [action], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); +} + +/// A class which mocks [Ticker]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTicker extends _i1.Mock implements _i11.Ticker { + @override + bool get muted => (super.noSuchMethod( + Invocation.getter(#muted), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + set muted(bool? value) => super.noSuchMethod( + Invocation.setter( + #muted, + value, + ), + returnValueForMissingStub: null, + ); + + @override + bool get isTicking => (super.noSuchMethod( + Invocation.getter(#isTicking), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isActive => (super.noSuchMethod( + Invocation.getter(#isActive), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get scheduled => (super.noSuchMethod( + Invocation.getter(#scheduled), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get shouldScheduleTick => (super.noSuchMethod( + Invocation.getter(#shouldScheduleTick), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i7.TickerFuture start() => (super.noSuchMethod( + Invocation.method( + #start, + [], + ), + returnValue: _FakeTickerFuture_10( + this, + Invocation.method( + #start, + [], + ), + ), + returnValueForMissingStub: _FakeTickerFuture_10( + this, + Invocation.method( + #start, + [], + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.DiagnosticsNode describeForError(String? name) => (super.noSuchMethod( + Invocation.method( + #describeForError, + [name], + ), + returnValue: _FakeDiagnosticsNode_12( + this, + Invocation.method( + #describeForError, + [name], + ), + ), + returnValueForMissingStub: _FakeDiagnosticsNode_12( + this, + Invocation.method( + #describeForError, + [name], + ), + ), + ) as _i7.DiagnosticsNode); + + @override + void stop({bool? canceled = false}) => super.noSuchMethod( + Invocation.method( + #stop, + [], + {#canceled: canceled}, + ), + returnValueForMissingStub: null, + ); + + @override + void scheduleTick({bool? rescheduling = false}) => super.noSuchMethod( + Invocation.method( + #scheduleTick, + [], + {#rescheduling: rescheduling}, + ), + returnValueForMissingStub: null, + ); + + @override + void unscheduleTick() => super.noSuchMethod( + Invocation.method( + #unscheduleTick, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void absorbTicker(_i11.Ticker? originalTicker) => super.noSuchMethod( + Invocation.method( + #absorbTicker, + [originalTicker], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + String toString({bool? debugIncludeStack = false}) => super.toString(); +} + +/// A class which mocks [TickerProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTickerProvider extends _i1.Mock implements _i7.TickerProvider { + @override + _i11.Ticker createTicker(_i11.TickerCallback? onTick) => (super.noSuchMethod( + Invocation.method( + #createTicker, + [onTick], + ), + returnValue: _FakeTicker_13( + this, + Invocation.method( + #createTicker, + [onTick], + ), + ), + returnValueForMissingStub: _FakeTicker_13( + this, + Invocation.method( + #createTicker, + [onTick], + ), + ), + ) as _i11.Ticker); +}