chore: add stupid_simple_sheet

This commit is contained in:
Developer
2026-05-17 07:54:59 +08:00
parent c76eb3bc9a
commit 702b41c29f
38 changed files with 5444 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
## 0.9.1+1
- **FIX**: dragging up a sheet whose highest snapping point was lower than 1.0 would incorrectly snap it to 1.0.
## 0.9.1
- **FIX**: incorrect snapping points used for cupertino secondary transitions in some cases.
- **FEAT**: add option to blur route behind `StupidSimpleGlassSheet` and customize `barrierColor`.
## 0.9.0
> Note: This release has breaking changes.
- **FEAT**: add `RouteSnapshotMode` that can be used to improve performance.
- **FEAT**: Add `StupidSimpleGlassSheetRoute` which uses the iOS 26 style.
- **BREAKING** **REFACTOR**: remove deprecated `topRadius` parameter.
- **BREAKING** **FEAT**: simplify `SheetSnappingConfig` to only support relative values.
Remove absolute pixel-based snapping which didn't work correctly.
## 0.8.0+1
- **FIX**: immediately start clearing overscroll when letting go.
## 0.8.0
> Note: This release has breaking changes.
- **FIX**: dragging up from a snap point doesn't perform one frame of scroll before transitioning to dragging anymore.
- **DOCS**: updated docs with more cookbooks and all settings.
- **BREAKING** **FEAT**: removed clip, background color and shape from `StupidSimpleSheetRoute`.
Added a `SheetBackground` widget as a replacement.
Wrap your content in `SheetBackground` to get the same effect as before.
This was done to allow more flexibility in customizing the sheet's appearance.
## 0.7.0
> Note: This release has breaking changes.
- **FIX**: clip behavior wasn't respected in sheets.
- **BREAKING** **FEAT**: add `backgroundColor` to `StupidSimpleSheetRoute`.
## 0.6.3
- **FIX**: improve clipping of overlayed sheets.
- **FEAT**: add `shape` parameter to `StupidSimpleCupertinoSheetRoute`.
Deprecated `topRadius` parameter in favor of it.
## 0.6.2+3
- **FIX**: don't interfere with `pop()` animation while sheet is being dragged.
## 0.6.2+2
- **FIX**: stop bounce when scrolling down while sheet is at top.
## 0.6.2+1
- **DOCS**: fixed an incorrect changelog entry.
## 0.6.2
- **FEAT**: add `topRadius` to cupertino sheet.
This allows matching the iOS 26.0 appearance more closely
- **FEAT**: add `originateAboveBottomViewInset` to `StupidSimpleSheet` and its mixin.
## 0.6.1
- **FEAT**: add `draggable` parameter to sheets that can be used to disable user drags (#206).
## 0.6.0+1
- **FIX**: `overrideSnappingConfig` and `animateToRelative` could interrupt sheet dismissal and break Navigator (#205).
## 0.6.0
> Note: This release has breaking changes.
- **BREAKING** **FEAT**: allow overriding the current sheet's snapping config from its controller (#203).
For this, `effectiveSnappingConfig` was introduced on `StupidSimpleSheetTransitionMixin`, which should be used for all snapping configuration calculations instead of `snappingConfig`.
If you only set `snappingConfig` and didn't do your own calculations with its values, you don't need to change anything.
## 0.5.0
> Note: This release has breaking changes.
- **BREAKING** **FIX**: don't call `Navigator.did[Start|Stop]UserGesture` by default to match Flutter sheets.
You can restore the old behavior by passing `callNavigatorUserGestureMethods = true` to your sheet route
## 0.4.2
- **FEAT**: add `StupidSimpleSheetController` that can be used from a sheets subtree for imperative control.
## 0.4.1
- **FIX**: velocity scaling when overdragging with resistance.
- **FIX**: secondary animation in cupertino sheet.
- **FEAT**: cupertino sheet can now be dragged over its limits with resistance.
- **FEAT**: sheets support snapping points now.
- **FEAT**: cupertino sheet top padding is based on safe area now.
## 0.4.0+2
- Update a dependency to the latest release.
## 0.4.0+1
- Update a dependency to the latest release.
## 0.4.0
> Note: This release has breaking changes.
- **BREAKING** **FEAT**: add `onlyDragWhenScrollWasAtTop` and default to true.
This allows sheets to prevent closing accidentally when the user just wanted to scroll to the top, especially in short lists. It matches iOS default behavior.
## 0.3.1
- **FEAT**: add `clearBarrierImmediately` setting that allows the route to make underlying routes interactible straight away (#183).
## 0.3.0+1
- **FIX**: routes below `StupidSimpleCupertinoSheetRoute` took too long to become interactable (#180).
## 0.3.0
- Graduate package to a stable release. See pre-releases prior to this version for changelog entries.
## 0.3.0-dev.3
- **FIX**: import internal from package:meta again.
## 0.3.0-dev.2
- **FEAT**: add clip behavior option to sheet (#173).
## 0.3.0-dev.1
- **FIX**: don't render overscroll indicators while we drag.
- **FIX**: only pay attention to drags on the axis we care about.
- **FIX**: only pay attention to relevant axes.
## 0.3.0-dev.0
- **BUILD**: fixed package versioning
## 0.0.2-dev.2
- **FIX**: default to `snapToEnd` in sheet motions for compatibility with the latest motor update.
## 0.0.2-dev.1
- Update a dependency to the latest release.
## 0.0.2-dev.0+1
- **FIX**: sheets don't incorrectly use an old drag end velocity anymore.
## 0.0.2
- **FIX**: remove hard-coded padding from sheet (#155).
- **FEAT**: stupid simple sheet and drag detector (#154).

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tim Lehmann for whynotmake.it
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.

View File

@@ -0,0 +1,361 @@
# Stupid Simple Sheet
[![Pub Version](https://img.shields.io/pub/v/stupid_simple_sheet)](https://pub.dev/packages/stupid_simple_sheet)
[![Coverage](./coverage.svg)](./test/)
[![lintervention_badge]]([lintervention_link])
[![Bluesky](https://img.shields.io/badge/Bluesky-0285FF?logo=bluesky&logoColor=fff)](https://bsky.app/profile/i.madethese.works)
A simple yet powerful sheet widget for Flutter with seamless scroll-to-drag transitions.
## What makes it unique
**Smooth transitioning from any scrolling child to the drag gestures of the mobile sheet.** The sheet automatically detects when scrollable content reaches its bounds and seamlessly transitions to sheet dragging behavior - no complex gesture coordination required.
**Powered by Motor physics simulations** to make the sheet feel incredibly natural and responsive. The spring physics create smooth, realistic motion that feels right at home on any device.
The sheet works perfectly with:
- `ListView`
- `CustomScrollView`
- `PageView`
- Any scrollable widget
## Important Warning
**Content inside the sheet should not define any custom `ScrollConfiguration`.** The sheet relies on the default Flutter scroll behavior to properly detect scroll boundaries and transition between scrolling and dragging states.
## Installation
**In order to start using Stupid Simple Sheet you must have the [Flutter SDK][flutter_install_link] installed on your machine.**
Install via `flutter pub`:
```sh
flutter pub add stupid_simple_sheet
```
## Usage
### Understanding the Base Sheet
`StupidSimpleSheetRoute` is intentionally minimal - it provides **no background, shape, or SafeArea by default**. This gives you complete freedom to build any style of sheet:
```dart
Navigator.of(context).push(
StupidSimpleSheetRoute(
child: YourSheetContent(), // You control all styling
),
);
```
This design lets you create sheets that don't look like traditional sheets at all - floating cards, full-bleed content, custom shapes, or anything else you can imagine.
### Common Use Cases
#### 1. Standard Modal Sheet with Background
For a typical modal sheet with rounded corners and a background, wrap your content in `SheetBackground`:
```dart
Navigator.of(context).push(
StupidSimpleSheetRoute(
child: SafeArea(
// Most sheets should only avoid the top safe area, the rest should be avoided
// inside the sheet content as needed.
bottom: false,
left: false,
right: false,
child: SheetBackground(
child: YourContent(),
),
),
),
);
```
`SheetBackground` provides:
- Rounded superellipse shape (24px radius at top)
- Theme's surface color as background
- Anti-aliased clipping
- Automatic background extension to handle overdrag
You can customize it:
```dart
SheetBackground(
backgroundColor: Colors.blue.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
clipBehavior: Clip.hardEdge,
child: YourContent(),
)
```
#### 2. Cupertino-style Sheet
For iOS-style modal sheets that push the previous screen back:
![Cupertino Sheet](doc/cupertino.gif)
```dart
Navigator.of(context).push(
StupidSimpleCupertinoSheetRoute(
child: CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
largeTitle: Text('Sheet'),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: Text('Close'),
onPressed: () => Navigator.of(context).pop(),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => CupertinoListTile(
title: Text('Item #$index'),
),
childCount: 50,
),
),
],
),
),
),
);
```
#### 3. Small Floating Sheet (Resizing Content)
For sheets that size to fit their content and can grow/shrink dynamically:
![Resizing Sheet](doc/resizing.gif)
```dart
Navigator.of(context).push(
StupidSimpleSheetRoute(
motion: CupertinoMotion.smooth(),
originateAboveBottomViewInset: true, // Stays above keyboard
child: SafeArea(
child: Card(
margin: EdgeInsets.all(8),
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min, // Size to content
children: [
// Your content here
CupertinoTextField(placeholder: 'Type something...'),
// Content can grow dynamically
],
),
),
),
),
);
```
#### 4. Sheet with Snapping Points
Create sheets that snap to specific positions (e.g., half-open, full):
```dart
Navigator.of(context).push(
StupidSimpleCupertinoSheetRoute(
snappingConfig: SheetSnappingConfig(
[0.5, 1.0], // Snap at 50% and 100%
initialSnap: 0.5, // Start half-open
),
child: YourContent(),
),
);
```
#### 5. Non-Draggable Sheet
For sheets that can only be closed programmatically:
```dart
Navigator.of(context).push(
StupidSimpleCupertinoSheetRoute(
draggable: false,
child: YourContent(), // Must include a close button
),
);
```
#### 6. Sheet with PageView
The sheet handles horizontal paging seamlessly:
```dart
Navigator.of(context).push(
StupidSimpleCupertinoSheetRoute(
child: CupertinoPageScaffold(
child: PageView(
children: [
CustomScrollView(/* Page 1 content */),
CustomScrollView(/* Page 2 content */),
],
),
),
),
);
```
### Customizing Motion
Control the sheet's animation physics:
```dart
Navigator.of(context).push(
StupidSimpleSheetRoute(
motion: CupertinoMotion.bouncy(snapToEnd: true),
child: YourContent(),
),
);
```
### Programmatic Control with StupidSimpleSheetController
Control the sheet's position from within its content:
```dart
Navigator.of(context).push(
StupidSimpleSheetRoute(
child: Builder(
builder: (context) {
final controller = StupidSimpleSheetController.maybeOf<void>(context);
return Column(
children: [
ElevatedButton(
onPressed: () {
// Animate to half-open position
controller?.animateToRelative(0.5);
},
child: Text('Half Open'),
),
ElevatedButton(
onPressed: () {
// Animate to fully open with snapping
controller?.animateToRelative(0.8, snap: true);
},
child: Text('Almost Full (with snap)'),
),
],
);
},
),
),
);
```
#### Controller Methods
- **`maybeOf<T>(BuildContext context)`**: Retrieves the controller from a context within the sheet. Returns `null` if called from outside a sheet.
- **`animateToRelative(double position, {bool snap = false})`**: Animates the sheet to a relative position between 0.0 (closed) and 1.0 (fully open).
- **`overrideSnappingConfig(SheetSnappingConfig? config, {bool animateToComply = false})`**: Dynamically change or disable snapping behavior.
**Note**: The controller cannot close the sheet programmatically. To close the sheet, use `Navigator.pop(context)`.
### Custom Routes with Maximum Control
For advanced use cases, create custom routes using `StupidSimpleSheetTransitionMixin`:
```dart
class MyCustomSheetRoute<T> extends PopupRoute<T>
with StupidSimpleSheetTransitionMixin<T> {
MyCustomSheetRoute({
required this.child,
this.motion = const CupertinoMotion.smooth(snapToEnd: true),
});
final Widget child;
@override
final Motion motion;
@override
Widget buildContent(BuildContext context) {
return SafeArea(
bottom: false,
child: Stack(
children: [
Positioned.fill(
bottom: -1000,
child: Material(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
),
ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
child: child,
),
],
),
);
}
@override
double get overshootResistance => 50;
@override
Color? get barrierColor => Colors.black26;
}
```
### Background Snapshotting
Improve transition performance by rasterizing the route behind the sheet into a GPU texture via `backgroundSnapshotMode`:
```dart
Navigator.of(context).push(
StupidSimpleCupertinoSheetRoute(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
child: YourContent(),
),
);
```
| Mode | Snapshots when |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `never` | Never (default). Background is always painted live. |
| `always` | Entire lifetime of the sheet. Best for static backgrounds. |
| `animating` | During animations and drags only. Live when settled. |
| `settled` | When settled only. Live during animations and drags. |
| `openAndForward` | During the opening animation to the max snap point, and while settled there. Live during drags, closing, and animations to intermediate snap points. |
When sheets stack (e.g. glass-on-glass), the mixin automatically wires up snapshotting between them via `maybeSnapshotChild`.
## Features
- **Seamless scroll transitions**: Automatically handles the transition between scrolling content and sheet dragging
- **Spring physics**: Natural motion using the `motor` package physics engine
- **Programmatic control**: Use `StupidSimpleSheetController` to animate the sheet position
- **Flexible styling**: Build any sheet style with `SheetBackground` or custom widgets
- **Cupertino integration**: Native iOS-style sheets with `StupidSimpleCupertinoSheetRoute`
- **Snapping**: Configure snap points for multi-stop sheets
- **Background snapshotting**: Rasterize the background route for better transition performance
- **Gesture coordination**: No need to manually handle gesture conflicts
- **Multiple scroll types**: Supports all Flutter scrollable widgets
- **Extensible architecture**: Use the mixin to create custom routes with full control
## Examples
Check out the [example app](./example/) to see the sheet in action with:
- Cupertino-style sheets with navigation bars
- Paged content with PageView
- Dynamically resizing sheets
- Snapping sheets with multiple stops
- Non-draggable sheets
---
[flutter_install_link]: https://flutter.dev/docs/get-started/install
[lintervention_link]: https://github.com/whynotmake-it/lintervention
[lintervention_badge]: https://img.shields.io/badge/lints_by-lintervention-3A5A40

View File

@@ -0,0 +1 @@
include: package:lintervention/analysis_options.yaml

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="230" height="20" role="img" aria-label="stupid_simple_sheet coverage: 88.24%"><title>stupid_simple_sheet coverage: 88.24%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="230" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="177" height="20" fill="#555"/><rect x="177" width="53" height="20" fill="#4c1"/><rect width="230" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="895" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="1670">stupid_simple_sheet coverage</text><text x="895" y="140" transform="scale(.1)" fill="#fff" textLength="1670">stupid_simple_sheet coverage</text><text aria-hidden="true" x="2025" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">88.24%</text><text x="2025" y="140" transform="scale(.1)" fill="#fff" textLength="430">88.24%</text></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,563 @@
SF:lib/stupid_simple_sheet.dart
DA:41,1
DA:94,1
DA:96,2
DA:98,0
DA:99,0
DA:100,0
DA:107,1
DA:109,1
DA:114,2
DA:133,2
DA:135,2
DA:136,2
DA:137,2
DA:138,6
DA:139,2
DA:140,4
DA:141,12
DA:145,2
DA:146,4
DA:147,8
DA:151,2
DA:179,1
DA:190,0
DA:201,3
DA:210,0
DA:216,0
DA:223,0
DA:236,0
DA:243,2
DA:245,2
DA:247,4
DA:248,4
DA:258,0
DA:260,0
DA:281,4
DA:282,8
DA:294,4
DA:295,4
DA:297,4
DA:298,8
DA:301,1
DA:302,2
DA:306,2
DA:307,2
DA:308,1
DA:309,1
DA:310,2
DA:311,3
DA:313,1
DA:314,4
DA:317,3
DA:318,2
DA:320,2
DA:321,2
DA:322,1
DA:323,1
DA:324,1
DA:329,4
DA:330,4
DA:331,4
DA:332,8
DA:336,4
DA:337,8
DA:339,4
DA:341,8
DA:343,4
DA:345,4
DA:346,4
DA:352,8
DA:357,4
DA:358,4
DA:359,4
DA:360,8
DA:362,8
DA:363,4
DA:367,4
DA:373,4
DA:375,8
DA:377,4
DA:380,4
DA:381,4
DA:382,8
DA:383,8
DA:384,6
DA:385,3
DA:386,3
DA:387,3
DA:388,3
DA:392,4
DA:396,4
DA:399,8
DA:400,0
DA:402,4
DA:403,4
DA:404,4
DA:407,4
DA:408,4
DA:409,12
DA:413,1
DA:420,1
DA:421,1
DA:422,1
DA:423,2
DA:425,1
DA:429,1
DA:430,1
DA:431,1
DA:432,2
DA:436,1
DA:437,1
DA:438,2
DA:448,4
DA:450,4
DA:451,4
DA:452,4
DA:453,4
DA:454,3
DA:455,4
DA:461,3
DA:464,6
DA:465,3
DA:466,3
DA:469,3
DA:472,3
DA:473,3
DA:474,3
DA:475,0
DA:479,3
DA:480,6
DA:481,6
DA:484,6
DA:486,6
DA:488,4
DA:491,1
DA:492,2
DA:493,2
DA:497,4
DA:499,2
DA:500,6
DA:502,2
DA:505,3
DA:507,6
DA:508,3
DA:511,3
DA:516,3
DA:517,6
DA:518,3
DA:519,0
DA:523,6
DA:525,3
DA:527,6
DA:530,6
DA:531,0
DA:534,0
DA:535,0
DA:536,0
DA:538,0
DA:540,0
DA:542,0
DA:543,0
DA:547,9
DA:553,3
DA:554,2
DA:557,4
DA:560,4
DA:562,4
DA:563,2
DA:566,3
DA:575,3
DA:577,3
DA:579,2
DA:587,4
DA:590,4
DA:591,4
DA:592,4
DA:594,4
DA:598,0
DA:601,0
DA:602,0
DA:605,4
DA:608,8
DA:609,8
DA:610,4
DA:625,1
DA:626,1
DA:627,1
DA:644,2
DA:646,6
DA:651,2
DA:656,3
DA:662,1
DA:663,1
DA:664,1
DA:671,4
DA:672,4
DA:674,4
DA:677,2
DA:678,4
DA:701,1
DA:706,1
DA:710,1
DA:713,1
DA:714,2
DA:717,2
DA:719,2
DA:724,3
DA:725,0
DA:728,2
DA:731,2
DA:734,1
DA:736,2
DA:739,1
LF:212
LH:187
end_of_record
SF:lib/src/clamped_animation.dart
DA:7,2
DA:12,2
DA:13,6
DA:18,4
DA:24,2
DA:37,2
DA:38,12
DA:43,2
DA:47,2
LF:9
LH:9
end_of_record
SF:lib/src/cupertino_sheet_copy.dart
DA:36,4
DA:37,4
DA:39,1
DA:44,1
DA:45,2
DA:47,1
DA:52,4
DA:55,2
DA:62,4
DA:66,4
DA:70,2
DA:72,2
DA:73,2
DA:74,2
DA:75,2
DA:77,2
DA:78,2
DA:87,1
DA:92,1
DA:93,1
DA:94,1
DA:95,1
DA:99,1
DA:100,1
DA:101,1
DA:111,1
DA:120,1
DA:122,1
DA:124,1
DA:129,3
DA:132,1
DA:134,1
DA:139,1
DA:141,2
DA:145,1
DA:149,1
DA:152,1
DA:156,1
DA:158,1
DA:162,2
DA:164,1
DA:167,1
DA:175,4
DA:177,1
DA:178,1
DA:179,1
DA:184,1
DA:186,1
DA:188,1
DA:192,1
DA:195,1
DA:196,1
DA:197,1
DA:208,1
DA:218,1
DA:220,1
DA:224,1
DA:225,1
DA:226,1
DA:227,1
DA:229,4
DA:234,1
DA:236,1
DA:240,2
DA:243,1
DA:245,2
DA:246,1
DA:247,1
DA:253,1
DA:256,1
DA:267,1
DA:281,1
DA:282,1
DA:283,1
DA:286,1
DA:288,2
DA:292,1
DA:296,1
DA:298,1
DA:300,1
DA:301,1
DA:302,1
DA:307,4
DA:308,1
DA:310,1
DA:312,1
LF:86
LH:86
end_of_record
SF:lib/src/optimized_clip.dart
DA:12,2
DA:25,2
DA:27,2
DA:29,0
DA:30,6
DA:31,2
DA:33,2
DA:35,3
DA:36,1
DA:38,1
DA:40,1
DA:41,0
DA:42,0
DA:44,2
DA:45,1
DA:46,1
DA:48,0
DA:49,0
DA:50,0
DA:53,0
LF:20
LH:13
end_of_record
SF:lib/src/glass_sheet_transitions.dart
DA:16,0
DA:25,0
DA:36,2
DA:49,2
DA:53,2
DA:54,2
DA:55,2
DA:56,2
DA:58,8
DA:62,2
DA:63,2
DA:66,2
DA:68,2
DA:72,4
DA:75,2
DA:77,2
DA:81,2
DA:84,2
DA:95,2
DA:110,2
DA:111,2
DA:112,2
DA:115,2
DA:117,2
DA:118,2
DA:119,2
DA:124,8
DA:125,2
DA:127,2
DA:128,2
DA:130,2
LF:31
LH:29
end_of_record
SF:lib/src/sheet_background.dart
DA:11,2
DA:52,2
DA:55,6
DA:56,2
DA:57,2
DA:58,4
DA:59,2
DA:60,4
DA:61,2
DA:62,2
DA:63,2
DA:64,2
DA:65,2
DA:66,2
LF:14
LH:14
end_of_record
SF:lib/src/snapping_point.dart
DA:20,1
DA:33,2
DA:35,2
DA:36,2
DA:39,4
DA:44,2
DA:49,2
DA:50,5
DA:51,2
DA:56,4
DA:58,0
DA:60,0
DA:63,2
DA:67,2
DA:69,2
DA:71,4
DA:72,4
DA:73,2
DA:87,2
DA:88,2
DA:89,0
DA:92,2
DA:93,6
DA:94,2
DA:96,4
DA:100,2
DA:101,2
DA:103,4
DA:105,10
DA:111,2
DA:112,2
DA:114,4
DA:115,6
DA:121,2
DA:122,2
DA:123,4
DA:127,0
DA:128,0
DA:129,0
DA:134,2
DA:135,10
DA:138,1
DA:141,0
DA:142,0
DA:143,0
DA:145,0
DA:146,0
DA:148,0
DA:149,0
LF:49
LH:36
end_of_record
SF:lib/src/stupid_simple_cupertino_sheet.dart
DA:15,1
DA:39,1
DA:61,1
DA:64,1
DA:65,2
DA:67,1
DA:70,1
DA:73,1
DA:93,1
DA:95,1
DA:97,2
DA:99,1
DA:101,1
DA:102,1
DA:103,1
DA:104,1
DA:105,1
DA:106,1
DA:107,1
DA:115,1
DA:117,1
DA:120,1
DA:124,1
DA:131,1
DA:133,1
DA:134,1
DA:135,1
DA:137,2
DA:139,2
DA:140,2
DA:141,1
DA:142,1
DA:143,1
DA:150,0
DA:152,0
DA:153,0
DA:156,1
DA:159,1
DA:161,1
DA:162,0
DA:164,1
DA:169,1
DA:171,0
DA:175,0
DA:178,0
DA:180,0
DA:182,0
LF:47
LH:38
end_of_record
SF:lib/src/stupid_simple_glass_sheet.dart
DA:26,2
DA:61,0
DA:90,2
DA:91,4
DA:93,2
DA:94,4
DA:96,2
DA:99,2
DA:102,2
DA:120,2
DA:122,4
DA:124,1
DA:125,1
DA:126,1
DA:137,2
DA:139,2
DA:140,2
DA:141,2
DA:142,4
DA:143,2
DA:144,2
DA:145,2
DA:146,2
DA:147,2
DA:148,2
DA:149,2
DA:159,2
DA:161,2
DA:164,2
DA:168,2
DA:175,2
DA:177,2
DA:178,2
DA:180,6
DA:181,2
DA:183,4
DA:185,2
DA:186,2
DA:187,2
DA:188,2
DA:189,2
DA:190,2
DA:197,2
DA:199,2
DA:200,0
DA:203,2
DA:205,4
DA:206,2
DA:209,2
DA:212,2
DA:214,2
DA:215,2
DA:217,2
DA:220,2
DA:224,2
DA:228,0
DA:231,0
DA:232,0
DA:234,0
LF:59
LH:53
end_of_record

View File

@@ -0,0 +1,29 @@
# example
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application that follows the
[simple app state management
tutorial](https://flutter.dev/to/state-management-sample).
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Assets
The `assets` directory houses images, fonts, and any other files you want to
include with your application.
The `assets/images` directory contains [resolution-aware
images](https://flutter.dev/to/resolution-aware-images).
## Localization
This project generates localized messages based on arb files found in
the `lib/src/localization` directory.
To support additional languages, please visit the tutorial on
[Internationalizing Flutter apps](https://flutter.dev/to/internationalization).

View File

@@ -0,0 +1,3 @@
linter:
rules:
public_member_api_docs: false

View File

@@ -0,0 +1,610 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:liquid_glass_renderer/liquid_glass_renderer.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(
CupertinoApp.router(
routerConfig: router.config(),
),
);
}
final stupidSimpleSheetRoutes = [
NamedRouteDef(
name: 'Stupid Simple Sheet Examples',
path: '',
type: RouteType.cupertino(),
builder: (context, state) => const MotorExample(),
),
NamedRouteDef(
name: 'Glass Sheet',
path: 'glass-sheet',
builder: (context, data) => _GlassSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleGlassSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
child: child,
),
),
),
NamedRouteDef(
name: 'Cupertino Sheet',
path: 'cupertino-sheet',
builder: (context, data) => _CupertinoSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleCupertinoSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
child: child,
),
),
),
NamedRouteDef(
name: 'Paged Sheet',
path: 'paged-sheet',
builder: (context, data) => _PagedSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleCupertinoSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
child: child,
),
),
),
NamedRouteDef(
name: 'Small Sheet',
path: 'small-sheet',
builder: (context, data) => _SmallSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
motion: CupertinoMotion.smooth(),
originateAboveBottomViewInset: true,
child: child,
),
),
),
NamedRouteDef(
name: 'Snapping Sheet',
path: 'snapping-sheet',
builder: (context, data) => _SnappingSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleCupertinoSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
snappingConfig: SheetSnappingConfig(
[0.5, 1.0],
initialSnap: .5,
),
child: child,
),
),
),
NamedRouteDef(
name: 'Non-Draggable Sheet',
path: 'non-draggable-sheet',
builder: (context, data) => _NonDraggableSheetContent(),
type: RouteType.custom(
customRouteBuilder: <T>(context, child, page) =>
StupidSimpleCupertinoSheetRoute<T>(
backgroundSnapshotMode: RouteSnapshotMode.openAndForward,
settings: page,
draggable: false,
child: child,
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
),
),
),
),
];
final router = RootStackRouter.build(
routes: [
NamedRouteDef.shell(
name: 'Home',
path: '/',
type: RouteType.cupertino(),
children: stupidSimpleSheetRoutes,
),
],
);
class MotorExample extends StatelessWidget {
const MotorExample({super.key});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar.large(
largeTitle: Text('Stupid Simple Sheet'),
),
child: Center(
child: Column(
spacing: 16,
mainAxisAlignment: MainAxisAlignment.center,
children: [
CupertinoButton.filled(
child: Text('Glass Sheet'),
onPressed: () => context.navigateTo(NamedRoute('Glass Sheet')),
),
CupertinoButton.filled(
child: Text('Cupertino Sheet'),
onPressed: () =>
context.navigateTo(NamedRoute('Cupertino Sheet')),
),
CupertinoButton.filled(
child: Text('Paged Sheet'),
onPressed: () => context.navigateTo(NamedRoute('Paged Sheet')),
),
CupertinoButton.filled(
child: Text('Resizing Sheet'),
onPressed: () => context.navigateTo(NamedRoute('Small Sheet')),
),
CupertinoButton.filled(
child: Text('Snapping Sheet'),
onPressed: () => context.navigateTo(NamedRoute('Snapping Sheet')),
),
CupertinoButton.filled(
child: Text('Non-Draggable Sheet'),
onPressed: () =>
context.navigateTo(NamedRoute('Non-Draggable Sheet')),
),
],
),
),
);
}
}
class _GlassSheetContent extends StatelessWidget {
const _GlassSheetContent();
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
PinnedHeaderSliver(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: _GlassSurface(
inLayer: false,
borderRadius: BorderRadius.circular(200),
child: Padding(
padding: EdgeInsetsGeometry.all(10),
child: Icon(
CupertinoIcons.xmark,
),
),
),
),
GestureDetector(
onTap: () => context.pushRoute(
NamedRoute('Glass Sheet'),
),
child: _GlassSurface(
color: CupertinoColors.activeBlue,
inLayer: false,
borderRadius: BorderRadius.circular(200),
child: Padding(
padding: EdgeInsetsGeometry.symmetric(
vertical: 10, horizontal: 16),
child: Center(
child: Text(
'Another',
style: CupertinoTheme.of(context)
.textTheme
.actionTextStyle
.copyWith(
fontWeight: FontWeight.w600,
color: CupertinoColors.white.withValues(),
),
),
),
),
),
)
],
),
),
),
),
),
SliverSafeArea(
sliver: SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: CupertinoTextField(
placeholder: 'Type something...',
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => CupertinoListTile(
title: Text('Item #$index'),
),
childCount: 50,
),
),
SliverToBoxAdapter(
child: CupertinoTextField(),
),
]))
],
),
);
}
}
class _CupertinoSheetContent extends StatelessWidget {
const _CupertinoSheetContent();
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
largeTitle: Text('Sheet'),
leading: CupertinoButton(
child: Text("Close"),
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).pop(),
),
),
SliverSafeArea(
sliver: SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: CupertinoTextField(
placeholder: 'Type something...',
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => CupertinoListTile(
title: Text('Item #$index'),
),
childCount: 50,
),
),
SliverToBoxAdapter(
child: CupertinoTextField(),
),
]))
],
),
);
}
}
class _GlassSurface extends StatelessWidget {
const _GlassSurface({
required this.borderRadius,
required this.inLayer,
required this.child,
this.color,
});
final BorderRadius borderRadius;
final bool inLayer;
final Color? color;
final Widget child;
@override
Widget build(BuildContext context) {
return LiquidStretch(
child: DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedSuperellipseBorder(borderRadius: borderRadius),
shadows: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.1),
blurStyle: BlurStyle.outer,
blurRadius: 8,
offset: Offset(0, 0),
),
],
),
child: _buildContent(context),
),
);
}
Widget _buildContent(
BuildContext context,
) {
if (inLayer) {
return LiquidGlass(
shape: LiquidRoundedSuperellipse(
borderRadius: borderRadius.topLeft.x,
),
child: GlassGlow(child: child),
);
}
return LiquidGlass.withOwnLayer(
fake: true,
settings: LiquidGlassSettings(
glassColor: color ??
CupertinoTheme.of(context).barBackgroundColor.withValues(alpha: .7),
thickness: 30,
ambientStrength: .1,
saturation: 4,
lightIntensity: .4,
blur: 4,
),
shape: LiquidRoundedSuperellipse(
borderRadius: borderRadius.topLeft.x,
),
child: GlassGlow(
child: IconTheme(
data: IconThemeData(
color: CupertinoTheme.of(context).textTheme.textStyle.color),
child: child,
),
),
);
}
}
class _SnappingSheetContent extends StatefulWidget {
const _SnappingSheetContent();
@override
State<_SnappingSheetContent> createState() => _SnappingSheetContentState();
}
class _SnappingSheetContentState extends State<_SnappingSheetContent> {
bool _snapDisabled = false;
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
largeTitle: Text('Sheet'),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
child: Text(_snapDisabled ? 'Enable Snaps' : 'Disable Snaps'),
onPressed: () {
final controller =
StupidSimpleSheetController.maybeOf(context);
controller
?.overrideSnappingConfig(
_snapDisabled ? null : SheetSnappingConfig.full,
animateToComply: true,
)
.ignore();
setState(() {
_snapDisabled = !_snapDisabled;
});
}),
leading: CupertinoButton(
child: Text("Close"),
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).pop(),
),
),
SliverFillRemaining(
child: Center(
child: CupertinoButton.tinted(
child: Text('Another'),
onPressed: () =>
context.pushRoute(NamedRoute('Snapping Sheet')),
),
),
),
],
),
);
}
}
class _PagedSheetContent extends StatelessWidget {
const _PagedSheetContent();
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: PageView(
children: [
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => CupertinoListTile(
title: Text('Item #$index'),
),
childCount: 50,
),
),
],
),
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => CupertinoListTile(
title: Text('Item #$index'),
),
childCount: 50,
),
),
],
),
],
),
);
}
}
class _NonDraggableSheetContent extends StatelessWidget {
const _NonDraggableSheetContent();
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
largeTitle: Text('Non-Draggable Sheet'),
leading: CupertinoButton(
child: Text("Close"),
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).pop(),
),
),
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16,
children: [
Text(
'This sheet cannot be dragged!',
style: CupertinoTheme.of(context).textTheme.textStyle,
textAlign: TextAlign.center,
),
Text(
'Use the Close button to dismiss.',
style: CupertinoTheme.of(context)
.textTheme
.textStyle
.copyWith(color: CupertinoColors.secondaryLabel),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
}
class _SmallSheetContent extends StatefulWidget {
const _SmallSheetContent();
@override
State<_SmallSheetContent> createState() => _SmallSheetContentState();
}
class _SmallSheetContentState extends State<_SmallSheetContent> {
List<String> items = List.generate(
5,
(index) => 'Item ${index + 1}',
);
late final textController = TextEditingController();
late final focusNode = FocusNode();
@override
void dispose() {
textController.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Card(
elevation: 0,
margin: EdgeInsets.all(16),
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(16),
),
color: CupertinoColors.secondarySystemGroupedBackground
.resolveFrom(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
alignment: Alignment.topCenter,
duration: CupertinoMotion.smooth().duration,
curve: CupertinoMotion.smooth().toCurve,
child: Column(
children: [
CupertinoTextField.borderless(
focusNode: focusNode,
controller: textController,
padding: EdgeInsetsGeometry.all(16),
autofocus: true,
placeholder: 'Type something...',
onSubmitted: (_) => _addItem(),
),
for (var i = 0; i < items.length; i++)
CupertinoListTile(
title: Text(items[i]),
),
],
),
),
),
),
Divider(
color: CupertinoColors.opaqueSeparator.resolveFrom(context),
height: 1,
),
Row(
children: [
Expanded(
child: CupertinoButton(
foregroundColor: CupertinoColors.destructiveRed,
child: Text('Close'),
onPressed: () {
Navigator.of(context).pop();
},
),
),
Expanded(
child: CupertinoButton(
child: Text('Add Item'),
onPressed: () => _addItem(),
),
),
],
),
],
),
),
);
}
void _addItem() {
setState(() {
final text = textController.text.isEmpty
? 'Item ${items.length + 1}'
: textController.text;
items.add(text);
textController.clear();
focusNode.requestFocus();
});
}
}

View File

@@ -0,0 +1,31 @@
name: stupid_simple_sheet_example
description: "A new Flutter project."
# Prevent accidental publishing to pub.dev.
publish_to: 'none'
version: 1.0.0+1
resolution: workspace
environment:
sdk: ^3.5.4
flutter: ^3.27.0
dependencies:
auto_route: ^10.0.1
flutter:
sdk: flutter
liquid_glass_renderer:
git:
url: https://github.com/whynotmake-it/flutter_liquid_glass
path: packages/liquid_glass_renderer
ref: 2c5f88acefea99d4d7e22663946b623adc06bc3c
stupid_simple_sheet:
path: ../
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

View File

@@ -0,0 +1,48 @@
import 'package:flutter/animation.dart';
import 'package:meta/meta.dart';
@internal
class ClampedAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
ClampedAnimation(this.parent);
@override
final Animation<double> parent;
@override
double get value => parent.value.clamp(0.0, 1.0);
}
@internal
extension ClampedAnimationX on Animation<double> {
Animation<double> get clamped => ClampedAnimation(this);
}
@internal
class RemappedAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
RemappedAnimation(
this.parent, {
this.start = 0.0,
this.end = 1.0,
});
@override
final Animation<double> parent;
final double start;
final double end;
@override
double get value => Interval(start, end).transform(parent.value);
}
@internal
extension RemappedAnimationX on Animation<double> {
Animation<double> remapped({
double start = 0.0,
double end = 1.0,
}) =>
RemappedAnimation(this, start: start, end: end);
}

View File

@@ -0,0 +1,330 @@
/// A place for pasting source from cupertinos `sheets.dart`.
/// We ignore lints here so that we can copy the code without modification.
library;
// ignore_for_file: type=lint
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stupid_simple_sheet/src/clamped_animation.dart';
import 'package:stupid_simple_sheet/src/optimized_clip.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
// Smoothing factor applied to the device's top padding (which approximates the corner radius)
// to achieve a smoother end to the corner radius animation. A value of 1.0 would use
// the full top padding. Values less than 1.0 reduce the effective corner radius, improving
// the animation's appearance. Determined through empirical testing.
const double kDeviceCornerRadiusSmoothingFactor = 0.9;
// Threshold in logical pixels. If the calculated device corner radius (after applying
// the smoothing factor) is below this value, the corner radius transition animation will
// start from zero. This prevents abrupt transitions for devices with small or negligible
// corner radii. This value, combined with the smoothing factor, corresponds roughly
// to double the targeted radius of 12. Determined through testing and visual inspection.
const double kRoundedDeviceCornersThreshold = 20.0;
// Amount the sheet in the background scales down. Found by measuring the width
// of the sheet in the background and comparing against the screen width on the
// iOS simulator showing an iPhone 16 pro running iOS 18.0. The scale transition
// will go from a default of 1.0 to 1.0 - _kSheetScaleFactor.
const double kSheetScaleFactor = 0.0835;
const kSheetPaddingToPrevious = 11.0;
const double kSheetSlideDownFactor = 0.03;
final Animatable<double> kScaleTween =
Tween<double>(begin: 1.0, end: 1.0 - kSheetScaleFactor);
double getRelativeTopPadding(
BuildContext context, {
double extraPadding = 0,
double minFraction = 0.05,
}) {
final safeArea = MediaQuery.paddingOf(context);
final height = MediaQuery.sizeOf(context).height;
if (height == 0) {
return minFraction;
}
// Ensure that the sheet moves down by at least 5% of the screen height if
// the safe area is very small (e.g. no notch).
return max((safeArea.top + extraPadding) / height, minFraction);
}
Widget getOverlayedChild(
BuildContext context,
Widget? child,
Animation<double> animation,
bool secondLayer,
) {
final bool isDarkMode =
CupertinoTheme.brightnessOf(context) == Brightness.dark;
final overlayColor = isDarkMode && !secondLayer
? const Color(0xFFc8c8c8)
: const Color(0xFF000000);
final opacity = animation.drive(Tween(
begin: 0.0,
end: secondLayer && isDarkMode ? 0.15 : 0.1,
));
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
if (child != null) child,
IgnorePointer(
child: FadeTransition(
opacity: opacity,
child: DecoratedBox(
decoration: BoxDecoration(color: overlayColor),
child: const SizedBox.expand(),
),
),
),
],
);
}
ShapeBorder getDeviceShape({
required ShapeBorder sheetShape,
required double deviceCornerRadius,
}) {
switch (sheetShape) {
case RoundedSuperellipseBorder():
return RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(deviceCornerRadius),
),
);
default:
return RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(deviceCornerRadius),
),
);
}
}
abstract class CopiedCupertinoSheetTransitions {
/// The primary delegated transition. Will slide a non [CupertinoSheetRoute] page down.
///
/// Provided to the previous route to coordinate transitions between routes.
static Widget? secondarySlideDownTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required ShapeBorder primaryShape,
Widget? child,
}) {
final Animatable<Offset> topDownTween = Tween<Offset>(
begin: Offset.zero,
end: Offset(
0,
getRelativeTopPadding(context),
),
);
final double deviceCornerRadius =
(MediaQuery.maybeViewPaddingOf(context)?.top ?? 0) *
kDeviceCornerRadiusSmoothingFactor;
final bool roundedDeviceCorners =
deviceCornerRadius > kRoundedDeviceCornersThreshold;
final deviceShape = getDeviceShape(
sheetShape: primaryShape,
deviceCornerRadius: roundedDeviceCorners ? deviceCornerRadius : 0,
);
final decorationTween = ShapeBorderTween(
begin: deviceShape,
end: primaryShape.scale(1 / 1.5),
);
final shapeAnimation = secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(decorationTween);
final Animation<Offset> slideAnimation = secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(topDownTween);
final Animation<double> scaleAnimation = secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(kScaleTween);
final Widget? contrastedChild = getOverlayedChild(
context,
child,
secondaryAnimation.remapped(
start: opacityRange.$1,
end: opacityRange.$2,
),
false,
);
final double topGapHeight =
MediaQuery.sizeOf(context).height * getRelativeTopPadding(context);
return Stack(
children: <Widget>[
AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarBrightness: Brightness.dark,
statusBarIconBrightness: Brightness.light,
),
child: SizedBox(height: topGapHeight, width: double.infinity),
),
SlideTransition(
position: slideAnimation,
child: ScaleTransition(
scale: scaleAnimation,
filterQuality: FilterQuality.medium,
alignment: Alignment.topCenter,
child: AnimatedBuilder(
animation: shapeAnimation,
child: contrastedChild,
builder: (BuildContext context, Widget? child) {
return OptimizedClip(
shape: shapeAnimation.value,
child: child!,
);
},
),
),
),
],
);
}
static Widget secondarySlideUpTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required Animation<ShapeBorder?> shapeAnimation,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required Color backgroundColor,
Widget? child,
}) {
return SlideTransition(
position: secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(
Tween<Offset>(
begin: Offset(0, 0),
end: Offset(
0,
-kSheetPaddingToPrevious / MediaQuery.sizeOf(context).height,
),
),
),
transformHitTests: false,
child: ScaleTransition(
scale: secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(kScaleTween),
alignment: Alignment.topCenter,
filterQuality: FilterQuality.medium,
child: AnimatedBuilder(
animation: shapeAnimation,
builder: (context, child) => SheetBackground(
shape: shapeAnimation.value!,
backgroundColor: CupertinoDynamicColor.resolve(
backgroundColor,
context,
),
child: child!,
),
child: getOverlayedChild(
context,
child,
secondaryAnimation.remapped(
start: opacityRange.$1,
end: opacityRange.$2,
),
true,
),
),
),
);
}
static Widget fullTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required Color backgroundColor,
ShapeBorder shape = const RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
),
Widget? child,
}) {
final offsetTween = Tween<Offset>(
begin: Offset(0, 1),
end: Offset(0, 0),
);
final shapeTween = ShapeBorderTween(
begin: shape,
end: shape.scale(1 / 1.5),
);
final shapeAnimation = secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(shapeTween);
final Animation<Offset> positionAnimation = animation.drive(offsetTween);
return Builder(
builder: (context) {
return SafeArea(
left: false,
right: false,
bottom: false,
minimum:
EdgeInsets.only(top: MediaQuery.sizeOf(context).height * 0.05),
child: Padding(
padding: const EdgeInsets.only(top: kSheetPaddingToPrevious),
child: SlideTransition(
position: positionAnimation,
child: secondarySlideUpTransition(
context,
animation: animation,
secondaryAnimation: secondaryAnimation,
shapeAnimation: shapeAnimation,
opacityRange: opacityRange,
slideBackRange: slideBackRange,
backgroundColor: backgroundColor,
child: child,
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,160 @@
/// Sheet transitions for the iOS 26 liquid glass style.
///
/// Reuses shared constants and helpers from [cupertino_sheet_copy.dart].
/// Only the methods that differ from [CopiedCupertinoSheetTransitions] are
/// defined here.
library;
// ignore_for_file: type=lint
import 'package:flutter/cupertino.dart';
import 'package:stupid_simple_sheet/src/clamped_animation.dart';
import 'package:stupid_simple_sheet/src/cupertino_sheet_copy.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
abstract class GlassSheetTransitions {
/// Reuses the identical implementation from the iOS 18 transitions.
static Widget? secondarySlideDownTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required ShapeBorder primaryShape,
Widget? child,
}) {
return CopiedCupertinoSheetTransitions.secondarySlideDownTransition(
context,
animation: animation,
secondaryAnimation: secondaryAnimation,
opacityRange: opacityRange,
slideBackRange: slideBackRange,
primaryShape: primaryShape,
child: child,
);
}
static Widget secondarySlideUpTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required ShapeBorder shape,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required Color backgroundColor,
required bool secondSheet,
Widget? child,
}) {
final slideAnimation = secondSheet
? secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(
Tween<Offset>(
begin: Offset(0, 0),
end: Offset(
0,
-kSheetPaddingToPrevious / MediaQuery.sizeOf(context).height,
),
),
)
: secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(
Tween<Offset>(
begin: Offset(0, 0),
end: Offset(
0,
kSheetSlideDownFactor,
),
),
);
return SlideTransition(
position: slideAnimation,
transformHitTests: false,
child: ScaleTransition(
scale: secondaryAnimation
.remapped(
start: slideBackRange.$1,
end: slideBackRange.$2,
)
.drive(kScaleTween),
alignment: Alignment.topCenter,
filterQuality: FilterQuality.medium,
child: SheetBackground(
shape: shape,
backgroundColor: CupertinoDynamicColor.resolve(
backgroundColor,
context,
),
child: getOverlayedChild(
context,
child,
secondaryAnimation.remapped(
start: opacityRange.$1,
end: opacityRange.$2,
),
true,
),
),
),
);
}
static Widget fullTransition(
BuildContext context, {
required Animation<double> animation,
required Animation<double> secondaryAnimation,
required (double, double) opacityRange,
required (double, double) slideBackRange,
required Color backgroundColor,
required bool secondSheet,
ShapeBorder shape = const RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(36),
),
),
Widget? child,
}) {
final offsetTween = Tween<Offset>(
begin: Offset(0, 1),
end: Offset(0, 0),
);
final Animation<Offset> positionAnimation = animation.drive(offsetTween);
return Builder(
builder: (context) {
return SafeArea(
left: false,
right: false,
bottom: false,
minimum:
EdgeInsets.only(top: MediaQuery.sizeOf(context).height * 0.05),
child: Padding(
padding:
EdgeInsets.only(top: secondSheet ? kSheetPaddingToPrevious : 0),
child: SlideTransition(
position: positionAnimation,
child: secondarySlideUpTransition(
context,
animation: animation,
secondaryAnimation: secondaryAnimation,
shape: shape,
opacityRange: opacityRange,
slideBackRange: slideBackRange,
backgroundColor: backgroundColor,
secondSheet: secondSheet,
child: child,
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// Clips its child using the given [shape].
///
/// Compared to using [ClipPath] directly, this widget optimizes for
/// performance by using more efficient clipping methods when possible.
///
/// If [shape] is null, no clipping is applied.
@internal
class OptimizedClip extends StatelessWidget {
const OptimizedClip({
required this.shape,
required this.child,
this.clipBehavior = Clip.antiAlias,
super.key,
});
final ShapeBorder? shape;
final Clip clipBehavior;
final Widget child;
@override
Widget build(BuildContext context) {
final shape = this.shape;
return switch (shape) {
null => child,
RoundedSuperellipseBorder(:final borderRadius) => ClipRSuperellipse(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
child: child,
),
RoundedRectangleBorder(:final borderRadius) => ClipRRect(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
child: child,
),
OvalBorder() => ClipOval(
clipBehavior: clipBehavior,
child: child,
),
LinearBorder() => ClipRect(
clipBehavior: clipBehavior,
child: child,
),
_ => ClipPath(
clipBehavior: clipBehavior,
clipper: ShapeBorderClipper(
shape: shape,
),
child: child,
)
};
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/cupertino.dart';
/// Controls when the route behind the sheet is rasterized to a GPU texture
/// instead of being painted live.
///
/// Snapshotting replaces the background route's widget tree with a frozen
/// image, eliminating the cost of rebuilding and painting complex widgets
/// during sheet transitions and while the sheet is open.
///
/// See also:
///
/// * [SnapshotWidget], the Flutter widget that performs the rasterization.
enum RouteSnapshotMode {
/// Never snapshot. The background route is always painted live.
///
/// Use this when the content behind the sheet contains animations, video, or
/// other dynamic content that must remain live at all times.
never,
/// Snapshot only while the sheet animation is playing (opening, closing,
/// or being dragged). Reverts to the live widget tree when the animation
/// settles.
///
/// Good for cases where transition performance matters but background content
/// must stay live when the sheet is idle.
animating,
/// Snapshot while the sheet is open and settled. Reverts to live during
/// all animations (opening, closing, and drag gestures).
///
/// Useful when the transition involves visual effects that don't rasterize
/// well, but idle performance matters.
settled,
/// Snapshot while the sheet animates toward its fully-open (max extent)
/// position, and while it is settled there. Reverts to the live widget tree
/// during dismiss gestures, closing animations, and animations toward
/// intermediate snap points.
///
/// "Forward" only counts when the animation target is the maximum snapping
/// point. Animations to intermediate snap points are not snapshotted.
///
/// Best default for most sheets: maximum performance during the opening
/// transition and while idle, with real content visible as the user drags to
/// dismiss.
openAndForward,
/// Always snapshot while the sheet is present.
///
/// Maximum performance. The background is completely frozen for the lifetime
/// of the sheet. Best for static background content.
always,
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:motor/motor.dart';
import 'package:stupid_simple_sheet/src/optimized_clip.dart';
/// A widget that provides a background for a sheet, including shape and color.
///
/// Will also extend the background color below the sheet to account for
/// dragging the sheet further than its content height.
class SheetBackground extends StatelessWidget {
/// Creates a [SheetBackground].
const SheetBackground({
required this.child,
super.key,
this.backgroundColor,
this.shape = const RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
this.clipBehavior = Clip.antiAlias,
this.extensionAtBottom,
});
/// The shape that the sheet should have.
///
/// The child will be clipped to fit that shape, if [clipBehavior] is not
/// [Clip.none].
/// Defaults to a rounded superellipse with 24px radius at the top.
final ShapeBorder shape;
/// The background color of the sheet.
///
/// If null, the default background color from the current [Theme]s
/// surface color is used.
final Color? backgroundColor;
/// The [Clip] behavior to use for the sheet's content.
///
/// Defaults to [Clip.antiAlias].
/// If you set this to [Clip.none], the sheet's content will not be clipped.
final Clip clipBehavior;
/// How much to extend the sheet background below the sheet itself.
////
/// This is useful to cover up any content below the sheet when the user
/// drags the sheet up further than its content height.
///
/// If null (default), it will extend by the full height of the screen.
final double? extensionAtBottom;
/// The content of the sheet.
final Widget? child;
@override
Widget build(BuildContext context) {
final bottomExtension =
extensionAtBottom ?? MediaQuery.sizeOf(context).height;
final color = backgroundColor ?? Theme.of(context).colorScheme.surface;
return PaddingExtended(
padding: EdgeInsets.only(bottom: -bottomExtension),
child: DecoratedBox(
decoration: ShapeDecoration(shape: shape, color: color),
child: Padding(
padding: EdgeInsets.only(bottom: bottomExtension),
child: OptimizedClip(
clipBehavior: clipBehavior,
shape: shape,
child: child ?? const SizedBox.shrink(),
),
),
),
);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/foundation.dart';
/// Configuration for sheet snapping behavior.
///
/// Values are normalized between 0.0 and 1.0, where 0.0 is closed and 1.0
/// is fully open. The 0.0 point is always implicit and doesn't need to be
/// specified.
///
/// Example:
/// ```dart
/// SheetSnappingConfig([0.5, 1.0]) // Half and full height
/// ```
@immutable
class SheetSnappingConfig {
/// Creates a snapping configuration.
///
/// Values are normalized between 0.0 and 1.0, where 0.0 is closed and 1.0
/// is fully open. The 0.0 point is always implicit and doesn't need to be
/// specified.
const SheetSnappingConfig(this.points, {double? initialSnap})
: _initialSnap = initialSnap;
/// A snapping configuration that only includes the fully open state (1.0).
static const SheetSnappingConfig full = SheetSnappingConfig([1.0]);
/// The raw snapping points as provided to the constructor.
final List<double> points;
final double? _initialSnap;
/// Gets all snapping points including the implicit 0, resolved as relative
/// values (0.0-1.0) for the given sheet height.
List<double> getAllPoints() {
final resolved = <double>{
0.0, // Always include the implicit 0 point
...points,
};
return resolved.toList()..sort();
}
/// Gets the closest snapping point to the given relative position and
/// velocity.
double findTargetSnapPoint(
double currentRelativePosition,
double velocity, {
bool includeClosed = true,
}) {
final allPoints = getAllPoints()
.where((p) => includeClosed || p > 0) // Exclude 0 if needed
.toList();
// For high velocity, predict where the sheet would naturally settle
const velocityThreshold = 0.5;
if (velocity.abs() > velocityThreshold) {
// High velocity - predict the natural settling position
final projectedPosition = currentRelativePosition - (velocity * 0.3);
return _findClosestPoint(allPoints, projectedPosition);
} else {
// Low velocity - snap to the closest point based on current position
return _findClosestPoint(allPoints, currentRelativePosition);
}
}
double _findClosestPoint(List<double> points, double target) {
var minDistance = double.infinity;
var closest = points.first;
for (final point in points) {
final distance = (target - point).abs();
if (distance < minDistance) {
minDistance = distance;
closest = point;
}
}
return closest;
}
/// Gets the initial snap point as a relative value.
///
/// If [initialSnap] is set, returns that point resolved to
/// relative.
/// Otherwise, returns the lowest non-zero snap point, or 1.0 as fallback.
double get initialSnap {
if (_initialSnap case final p?) {
return p.clamp(0.0, 1.0);
}
final relativePoints = getAllPoints()
.where((value) => value > 0.001) // Exclude values effectively zero
.toList();
return relativePoints.isNotEmpty ? relativePoints.first : 1.0;
}
/// Gets the top two snap points for transition calculations.
(double, double) get topTwoPoints {
final allPoints = getAllPoints();
final lastPoint = allPoints.isNotEmpty ? allPoints.last : 1.0;
final secondLastPoint =
allPoints.length > 1 ? allPoints[allPoints.length - 2] : 0.0;
return (secondLastPoint, lastPoint);
}
/// Gets the bottom two snap points for opacity range calculations.
(double, double) get bottomTwoPoints {
final allPoints = getAllPoints();
final firstPoint = allPoints.isNotEmpty ? allPoints.first : 0.0;
final secondPoint = allPoints.length > 1 ? allPoints[1] : 1.0;
return (firstPoint, secondPoint);
}
/// Gets the maximum extent as a relative value.
double get maxExtent {
final allPoints = getAllPoints();
return allPoints.isNotEmpty ? allPoints.last : 1.0;
}
/// Gets the minimum extent as a relative value.
double get minExtent {
final allPoints = getAllPoints();
return allPoints.isNotEmpty ? allPoints.first : 0.0;
}
/// Whether this configuration has any in-between snap points
/// (not just 0 and 1).
bool get hasInbetweenSnaps {
return points.any((p) => p < 1.0 && p > 0.0);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SheetSnappingConfig &&
runtimeType == other.runtimeType &&
listEquals(points, other.points);
@override
int get hashCode => Object.hashAll([runtimeType, ...points.toList()..sort()]);
@override
String toString() => 'SheetSnappingConfig($points)';
}

View File

@@ -0,0 +1,185 @@
// ignore_for_file: avoid_positional_boolean_parameters
import 'package:flutter/cupertino.dart';
import 'package:stupid_simple_sheet/src/clamped_animation.dart';
import 'package:stupid_simple_sheet/src/cupertino_sheet_copy.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
/// Simular to [CupertinoSheetRoute] but with the drag gesture improvements from
/// this package.
class StupidSimpleCupertinoSheetRoute<T> extends PopupRoute<T>
with StupidSimpleSheetTransitionMixin<T>, StupidSimpleSheetController<T> {
/// Creates a sheet route for displaying modal content.
///
/// The [motion] and [child] arguments must not be null.
StupidSimpleCupertinoSheetRoute({
required this.child,
super.settings,
this.motion = const CupertinoMotion.smooth(
duration: Duration(milliseconds: 350),
snapToEnd: true,
),
this.clearBarrierImmediately = true,
this.backgroundColor = CupertinoColors.systemBackground,
this.callNavigatorUserGestureMethods = false,
this.snappingConfig = SheetSnappingConfig.full,
this.draggable = true,
this.originateAboveBottomViewInset = false,
this.backgroundSnapshotMode = RouteSnapshotMode.never,
this.shape = iOS18Shape,
});
/// The default iOS 18 shape for sheet controllers.
static const iOS18Shape = RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
);
@override
double get overshootResistance => 5000;
@override
final Motion motion;
@override
final bool clearBarrierImmediately;
/// The background color of the sheet.
///
/// Will default [CupertinoColors.secondarySystemBackground] if not provided.
final Color backgroundColor;
/// The widget to display in the sheet.
final Widget child;
/// The shape of the sheet.
///
/// Defaults to [iOS18Shape].
final ShapeBorder shape;
@override
Color? get barrierColor => CupertinoColors.transparent;
@override
bool get barrierDismissible => effectiveSnappingConfig.hasInbetweenSnaps;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
@override
bool get opaque => false;
@override
final bool callNavigatorUserGestureMethods;
@override
final SheetSnappingConfig snappingConfig;
@override
final bool draggable;
@override
final bool originateAboveBottomViewInset;
@override
final RouteSnapshotMode backgroundSnapshotMode;
StupidSimpleSheetTransitionMixin<dynamic>? _nextSheet;
@override
DelegatedTransitionBuilder? get delegatedTransition =>
(context, animation, secondaryAnimation, canSnapshot, child) {
final snappingConfigForTransition =
_nextSheet?.effectiveSnappingConfig ?? effectiveSnappingConfig;
return CopiedCupertinoSheetTransitions.secondarySlideDownTransition(
context,
animation: animation.clamped,
secondaryAnimation: secondaryAnimation.clamped,
slideBackRange: snappingConfigForTransition.topTwoPoints,
opacityRange: snappingConfigForTransition.bottomTwoPoints,
primaryShape: shape,
child: SnapshotWidget(
controller: backgroundSnapshotController,
mode: SnapshotMode.permissive,
autoresize: true,
child: child,
),
);
};
@override
Widget buildContent(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated,
child: Builder(
builder: (context) {
return CopiedCupertinoSheetTransitions.fullTransition(
context,
animation: controller!.view,
secondaryAnimation: secondaryAnimation,
slideBackRange: effectiveSnappingConfig.topTwoPoints,
opacityRange: effectiveSnappingConfig.bottomTwoPoints,
backgroundColor: backgroundColor,
shape: shape,
child: maybeSnapshotChild(child),
);
},
),
);
}
@override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
return nextRoute is StupidSimpleCupertinoSheetRoute ||
super.canTransitionTo(nextRoute);
}
@override
@mustCallSuper
void didChangeNext(Route<dynamic>? nextRoute) {
super.didChangeNext(nextRoute);
if (nextRoute is StupidSimpleSheetTransitionMixin) {
_nextSheet = nextRoute;
} else {
_nextSheet = null;
}
// Force the internal secondary transition instead of the delegated one,
// so this sheet's own scale-down/slide-up animation plays correctly.
if (nextRoute is StupidSimpleCupertinoSheetRoute) {
// ignore: invalid_use_of_visible_for_testing_member
receivedTransition = null;
}
}
@override
@mustCallSuper
void didPopNext(Route<dynamic> nextRoute) {
super.didPopNext(nextRoute);
if (nextRoute is StupidSimpleCupertinoSheetRoute) {
// ignore: invalid_use_of_visible_for_testing_member
receivedTransition = null;
}
}
}

View File

@@ -0,0 +1,268 @@
// ignore_for_file: avoid_positional_boolean_parameters
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:stupid_simple_sheet/src/glass_sheet_transitions.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
import 'package:xianyan/core/utils/sheet_animation_notifier.dart';
/// A sheet route styled after the iOS 26 liquid glass aesthetic.
///
/// The first sheet of this kind that is pushed will blur the backdrop (if
/// [blurBehindBarrier] is true) and apply [barrierColor] to the route behind.
///
/// Any subsequent sheet of this kind will not apply a barrier color or blur,
/// since the previous sheet will transition using its own internal secondary
/// transition.
///
/// Similar to [StupidSimpleCupertinoSheetRoute] but uses the newer glass-style
/// transitions with a larger default corner radius and no delegated transition
/// to the previous route.
class StupidSimpleGlassSheetRoute<T> extends PopupRoute<T>
with StupidSimpleSheetTransitionMixin<T>, StupidSimpleSheetController<T> {
/// Creates a glass-style sheet route for displaying modal content.
///
/// The [child] argument must not be null.
StupidSimpleGlassSheetRoute({
required this.child,
super.settings,
this.motion = const CupertinoMotion.smooth(
duration: Duration(milliseconds: 350),
snapToEnd: true,
),
this.clearBarrierImmediately = true,
this.backgroundColor = CupertinoColors.systemBackground,
this.callNavigatorUserGestureMethods = false,
this.snappingConfig = SheetSnappingConfig.full,
this.draggable = true,
this.originateAboveBottomViewInset = false,
this.backgroundSnapshotMode = RouteSnapshotMode.never,
this.shape = glassShape,
this.blurBehindBarrier = true,
/// The color applied to the route behind the first glass sheet.
///
/// This barrier color is only used for the first pushed glass sheet; any
/// subsequent sheets rely on the previous sheet's internal transition and
/// do not apply an additional barrier.
///
/// Defaults to black with 15% opacity.
Color barrierColor =
const Color.from(alpha: .15, red: 0, green: 0, blue: 0),
}) : _barrierColor = barrierColor;
/// The default glass shape with a 36px superellipse corner radius.
static const glassShape = RoundedSuperellipseBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(36),
),
);
@override
double get overshootResistance => 5000;
@override
final Motion motion;
@override
final bool clearBarrierImmediately;
/// The background color of the sheet.
///
/// Defaults to [CupertinoColors.systemBackground].
final Color backgroundColor;
/// The widget to display in the sheet.
final Widget child;
/// The shape of the sheet.
///
/// Defaults to [glassShape].
final ShapeBorder shape;
/// Whether the first sheet will blur the backdrop when it appears.
///
/// Defaults to true.
final bool blurBehindBarrier;
final Color _barrierColor;
@override
Color? get barrierColor => _isSecondGlassSheet ? null : _barrierColor;
@override
bool get barrierDismissible => effectiveSnappingConfig.hasInbetweenSnaps;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
@override
bool get opaque => false;
@override
final bool callNavigatorUserGestureMethods;
@override
final SheetSnappingConfig snappingConfig;
@override
final bool draggable;
@override
final bool originateAboveBottomViewInset;
@override
final RouteSnapshotMode backgroundSnapshotMode;
@override
DelegatedTransitionBuilder? get delegatedTransition =>
backgroundSnapshotMode == RouteSnapshotMode.never
? null
: (context, animation, secondaryAnimation, canSnapshot, child) {
return SnapshotWidget(
controller: backgroundSnapshotController,
mode: SnapshotMode.permissive,
autoresize: true,
child: child,
);
};
bool _isSecondGlassSheet = false;
StupidSimpleSheetTransitionMixin<dynamic>? _nextSheet;
@override
Widget buildModalBarrier() {
return Stack(
children: [
super.buildModalBarrier(),
if (blurBehindBarrier && !_isSecondGlassSheet)
Positioned.fill(
child: ValueListenableBuilder(
valueListenable: animation ?? kAlwaysDismissedAnimation,
builder: (context, value, child) {
final progress = (value as num).toDouble();
final sigma = progress * 10;
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: const SizedBox.expand(),
);
},
),
),
],
);
}
@override
bool get onlyDragWhenScrollWasAtTop => false;
@override
Widget buildContent(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated,
child: Builder(
builder: (context) {
final snappingConfigForTransition =
_nextSheet?.effectiveSnappingConfig ?? effectiveSnappingConfig;
return GlassSheetTransitions.fullTransition(
context,
animation: controller!.view,
secondaryAnimation: secondaryAnimation,
slideBackRange: snappingConfigForTransition.topTwoPoints,
opacityRange: snappingConfigForTransition.bottomTwoPoints,
backgroundColor: backgroundColor,
secondSheet: _isSecondGlassSheet,
shape: shape,
child: maybeSnapshotChild(child),
);
},
),
);
}
@override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
return nextRoute is StupidSimpleGlassSheetRoute ||
super.canTransitionTo(nextRoute);
}
@override
void install() {
super.install();
animation?.addListener(_onAnimationChanged);
}
void _onAnimationChanged() {
final value = animation?.value ?? 0.0;
SheetAnimationNotifier.instance.updateLayer(this, value);
}
@override
void didChangePrevious(Route<dynamic>? previousRoute) {
_isSecondGlassSheet = previousRoute is StupidSimpleGlassSheetRoute;
super.didChangePrevious(previousRoute);
}
@override
@mustCallSuper
void didChangeNext(Route<dynamic>? nextRoute) {
super.didChangeNext(nextRoute);
if (nextRoute is StupidSimpleSheetTransitionMixin) {
_nextSheet = nextRoute;
} else {
_nextSheet = null;
}
if (nextRoute is StupidSimpleGlassSheetRoute) {
// Force the internal secondary transition instead of the delegated one,
// so this sheet's own scale-down/slide-up animation plays correctly.
// ignore: invalid_use_of_visible_for_testing_member
receivedTransition = null;
}
}
@override
@mustCallSuper
void didPopNext(Route<dynamic> nextRoute) {
super.didPopNext(nextRoute);
if (nextRoute is StupidSimpleGlassSheetRoute) {
// ignore: invalid_use_of_visible_for_testing_member
receivedTransition = null;
}
}
@override
bool didPop(T? result) {
animation?.addStatusListener(_onAnimationStatusChanged);
return super.didPop(result);
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
SheetAnimationNotifier.instance.updateLayer(this, 0.0);
SheetAnimationNotifier.instance.removeLayer(this);
animation?.removeStatusListener(_onAnimationStatusChanged);
animation?.removeListener(_onAnimationChanged);
}
}
}

View File

@@ -0,0 +1,742 @@
import 'package:flutter/cupertino.dart';
import 'package:motor/motor.dart';
import 'package:scroll_drag_detector/scroll_drag_detector.dart';
import 'package:stupid_simple_sheet/src/clamped_animation.dart';
import 'package:stupid_simple_sheet/src/route_snapshot_mode.dart';
import 'package:stupid_simple_sheet/src/snapping_point.dart';
export 'package:motor/src/motion.dart';
export 'src/route_snapshot_mode.dart';
export 'src/sheet_background.dart';
export 'src/snapping_point.dart';
export 'src/stupid_simple_cupertino_sheet.dart';
export 'src/stupid_simple_glass_sheet.dart';
/// A modal route that displays a sheet that slides up from the bottom.
///
/// The sheet can be dismissed by dragging down or by tapping the barrier.
/// The animation supports spring physics for natural motion using the
///
///
/// By default, when a modal route is replaced by another, the previous route
/// remains in memory. To free all the resources when this is not necessary, set
/// [maintainState] to false.
///
/// The type `T` specifies the return type of the route which can be supplied as
/// the route is popped from the stack via [Navigator.pop] when an optional
/// `result` can be provided.
///
/// See also:
///
/// * [StupidSimpleSheetTransitionMixin], for a mixin that provides sheet
/// transition
/// behavior for this modal route.
/// * [CupertinoModalPopupRoute], for a similar iOS-style modal popup.
class StupidSimpleSheetRoute<T> extends PopupRoute<T>
with StupidSimpleSheetTransitionMixin<T>, StupidSimpleSheetController<T> {
/// Creates a sheet route for displaying modal content.
///
/// The [motion] and [child] arguments must not be null.
StupidSimpleSheetRoute({
required this.child,
super.settings,
this.motion = const CupertinoMotion.smooth(snapToEnd: true),
this.barrierColor = const Color.fromRGBO(0, 0, 0, 0.2),
this.barrierDismissible = true,
this.barrierLabel,
this.clearBarrierImmediately = true,
this.onlyDragWhenScrollWasAtTop = true,
this.callNavigatorUserGestureMethods = false,
this.snappingConfig = SheetSnappingConfig.full,
this.draggable = true,
this.originateAboveBottomViewInset = false,
this.backgroundSnapshotMode = RouteSnapshotMode.never,
});
@override
final Motion motion;
/// The widget to display in the sheet.
final Widget child;
@override
final Color? barrierColor;
@override
final bool barrierDismissible;
@override
final String? barrierLabel;
@override
final bool clearBarrierImmediately;
@override
final bool onlyDragWhenScrollWasAtTop;
@override
final bool callNavigatorUserGestureMethods;
/// The base snapping configuration for the sheet.
@override
final SheetSnappingConfig snappingConfig;
@override
final bool draggable;
@override
final bool originateAboveBottomViewInset;
@override
final RouteSnapshotMode backgroundSnapshotMode;
@override
DelegatedTransitionBuilder? get delegatedTransition =>
backgroundSnapshotMode == RouteSnapshotMode.never
? null
: (context, animation, secondaryAnimation, canSnapshot, child) {
return SnapshotWidget(
controller: backgroundSnapshotController,
mode: SnapshotMode.permissive,
autoresize: true,
child: child,
);
};
@override
Widget buildContent(BuildContext context) {
return child;
}
}
class _RelativeGestureDetector extends StatelessWidget {
const _RelativeGestureDetector({
required this.scrollableCanMoveBack,
required this.onlyDragWhenScrollWasAtTop,
required this.onRelativeDragStart,
required this.onRelativeDragUpdate,
required this.onRelativeDragEnd,
required this.child,
});
final bool scrollableCanMoveBack;
final bool onlyDragWhenScrollWasAtTop;
final VoidCallback onRelativeDragStart;
// ignore: avoid_positional_boolean_parameters
final void Function(double, bool) onRelativeDragUpdate;
// ignore: avoid_positional_boolean_parameters
final void Function(double, bool) onRelativeDragEnd;
final Widget child;
@override
Widget build(BuildContext context) {
return ScrollDragDetector(
onlyDragWhenScrollWasAtTop: onlyDragWhenScrollWasAtTop,
scrollableCanMoveBack: scrollableCanMoveBack,
onVerticalDragStart: (details, _) => onRelativeDragStart(),
onVerticalDragEnd: (details, willScroll) {
onRelativeDragEnd(
details.velocity.pixelsPerSecond.dy / context.size!.height,
willScroll,
);
},
onVerticalDragUpdate: (details, wouldScroll) {
onRelativeDragUpdate(
details.primaryDelta! / context.size!.height,
wouldScroll,
);
},
child: child,
);
}
}
/// A mixin that provides sheet transition behavior for a [PopupRoute].
///
/// This mixin handles the slide-up animation, drag gesture detection,
/// and dismissal logic for sheet-style popups.
///
/// The sheet slides in from the bottom and can be dismissed by dragging down
/// or by tapping the barrier. The animation supports spring physics for
/// natural motion.
///
/// See also:
///
/// * [StupidSimpleSheetRoute], which is a [PopupRoute] that leverages this
/// mixin.
mixin StupidSimpleSheetTransitionMixin<T> on PopupRoute<T> {
/// Builds the primary contents of the sheet.
@protected
Widget buildContent(BuildContext context);
/// The motion configuration for the sheet animation.
Motion get motion;
/// How much resistance the sheet should give when the user tries to drag
/// it past it's fully opened state.
double get overshootResistance => 100;
/// {@template clearBarrierImmediately}
/// Whether this route should clear the modal barrier immediately when
/// dismissed.
///
/// This can make your app feel more responsive by letting the user interact
/// with the underlying content, while this sheet is still animating out.
///
/// Defaults to true.
/// {@endtemplate}
bool get clearBarrierImmediately => true;
/// {@template onlyDragWhenScrollWasAtTop}
/// Whether the sheet should only start being draggable when its scrollable
/// content was at the top whenever the user initiates a drag.
///
/// If this is true, and the user starts scrolling up from somewhere other
/// than the top, the scroll view will perform a normal overscroll.
///
/// This matches iOS sheet behavior and defaults to true.
/// {@endtemplate}
bool get onlyDragWhenScrollWasAtTop => true;
/// Whether the sheet can be dragged.
///
/// When false, the sheet cannot be moved by dragging and applies
/// resistance in both directions, similar to the resistance applied when
/// dragging past 1.0.
///
/// Defaults to true.
bool get draggable => true;
/// Whether the navigator's user gesture methods should be called when
/// dragging starts and ends.
///
/// Defaults to false.
bool get callNavigatorUserGestureMethods => false;
/// Whether the sheet's origin should be moved up by the bottom view inset of
/// the current [MediaQuery].
///
/// If this is true, and the keyboard is opened, the sheet will originate from
/// above the keyboard.
bool get originateAboveBottomViewInset => false;
/// Controls when the route behind this sheet is rasterized to a GPU texture.
///
/// When enabled, the previous route's widget tree is replaced with a frozen
/// image during the configured phases, eliminating rebuild/paint costs.
///
/// Defaults to [RouteSnapshotMode.never].
///
/// To use this, subclasses must also provide a [delegatedTransition] that
/// wraps the previous route's child in a [SnapshotWidget] using
/// [backgroundSnapshotController]. See [StupidSimpleSheetRoute] for an
/// example.
RouteSnapshotMode get backgroundSnapshotMode => RouteSnapshotMode.never;
/// The [SnapshotController] that toggles snapshotting of the background
/// route.
///
/// Subclasses should pass this to a [SnapshotWidget] wrapping the previous
/// route's child inside their [delegatedTransition].
@protected
SnapshotController get backgroundSnapshotController =>
_backgroundSnapshotController;
late final SnapshotController _backgroundSnapshotController =
SnapshotController();
/// The [SnapshotController] from the sheet route stacked on top of this one.
///
/// Set automatically when another [StupidSimpleSheetTransitionMixin] route
/// pushes on top. Used by [maybeSnapshotChild] to wrap this route's content.
SnapshotController? _coveredBySnapshotController;
bool _isUserDragging = false;
@override
bool get allowSnapshotting =>
backgroundSnapshotMode != RouteSnapshotMode.never;
SheetSnappingConfig? _snappingConfigOverride;
/// The base snapping configuration for the sheet, as provided by the
/// implementing class.
///
/// This is used as the fallback when no override is set via
/// [StupidSimpleSheetController.overrideSnappingConfig].
///
/// Defaults to only containing a relative point at 1.0 (fully open).
///
/// A fully closed point of 0.0 is always added implicitly.
@protected
SheetSnappingConfig get snappingConfig;
/// The currently active snapping configuration for the sheet.
///
/// This will return the override if one has been set via
/// [StupidSimpleSheetController.overrideSnappingConfig], otherwise it returns
/// [snappingConfig].
SheetSnappingConfig get effectiveSnappingConfig =>
_snappingConfigOverride ?? snappingConfig;
double? _dragEndVelocity;
double? _animationTargetValue;
/// Where the sheet should stick if [draggable] is false, or when
/// overshooting.
double? _stickingPoint;
/// Updates [_backgroundSnapshotController] based on the current
/// [backgroundSnapshotMode], animation state, and drag state.
void _updateSnapshotState() {
final mode = backgroundSnapshotMode;
if (mode == RouteSnapshotMode.never) {
_backgroundSnapshotController.allowSnapshotting = false;
return;
}
if (mode == RouteSnapshotMode.always) {
_backgroundSnapshotController.allowSnapshotting = true;
return;
}
final isAnimating = controller?.isAnimating ?? false;
final value = controller?.value ?? 0.0;
final isVisible = value > 0.001;
final isSettled = !isAnimating && !_isUserDragging && isVisible;
final maxExtent = effectiveSnappingConfig.maxExtent;
final isFullyOpen = (value - maxExtent).abs() < 0.001;
final isTargetingMax = _animationTargetValue != null &&
(_animationTargetValue! - maxExtent).abs() < 0.001;
final isMovingForward = isTargetingMax &&
((controller?.status.isAnimating ?? false) ||
(_animationTargetValue! > value));
_backgroundSnapshotController.allowSnapshotting = switch (mode) {
RouteSnapshotMode.animating => isAnimating || _isUserDragging,
RouteSnapshotMode.settled => isSettled,
RouteSnapshotMode.openAndForward => (isSettled && isFullyOpen) ||
(isAnimating && isMovingForward && !_isUserDragging),
_ => false, // never/always handled above
};
}
@override
Duration get transitionDuration => switch (motion) {
CurvedMotion(:final duration) => duration,
CupertinoMotion(:final duration) => duration,
_ => const Duration(milliseconds: 500),
};
@override
Animation<double>? get animation => super.animation?.clamped;
@override
Animation<double>? get secondaryAnimation =>
super.secondaryAnimation?.clamped;
@override
Simulation? createSimulation({required bool forward}) {
final v = _dragEndVelocity;
_dragEndVelocity = null;
// Get the appropriate end value
double endValue;
if (forward) {
// Opening: use initial snap point or default
endValue = effectiveSnappingConfig.initialSnap;
} else {
// Closing
endValue = 0.0;
}
_animationTargetValue = endValue;
_stickingPoint = endValue;
_updateSnapshotState();
return motion.createSimulation(
end: endValue,
start: animation?.value ?? 0,
velocity: -(v ?? 0),
);
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) => MediaQuery.removeViewInsets(
context: context,
removeBottom: originateAboveBottomViewInset,
// ^ The sheet is already moved up by the bottom view inset, so we make
// sure the content inside the sheet doesn't add extra padding
child: _RelativeGestureDetector(
onlyDragWhenScrollWasAtTop: onlyDragWhenScrollWasAtTop,
scrollableCanMoveBack: (_animationTargetValue ?? animation.value) <
effectiveSnappingConfig.maxExtent,
onRelativeDragStart: () => _handleDragStart(context),
onRelativeDragUpdate: (delta, wouldScroll) =>
_handleDragUpdate(context, delta, wouldScroll),
onRelativeDragEnd: (velocity, willScroll) =>
_handleDragEnd(context, velocity, willScroll),
child: child!,
),
),
child: buildContent(context),
);
}
@override
AnimationController createAnimationController() {
assert(
!debugTransitionCompleted(),
'Cannot reuse a $runtimeType after disposing it.',
);
final duration = transitionDuration;
final reverseDuration = reverseTransitionDuration;
final animationController = AnimationController.unbounded(
duration: duration,
reverseDuration: reverseDuration,
debugLabel: debugLabel,
vsync: navigator!,
)..addStatusListener((_) => _updateSnapshotState());
return animationController;
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return AnimatedBuilder(
animation: controller!,
builder: (context, _) {
final value = controller!.value;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: FractionalTranslation(
translation: Offset(0, 1 - value),
child: child,
),
),
if (originateAboveBottomViewInset)
SizedBox(
height: MediaQuery.viewInsetsOf(context).bottom,
),
],
);
},
);
}
final _poppedNotifier = ValueNotifier(false);
@override
Widget buildModalBarrier() {
return ValueListenableBuilder(
valueListenable: _poppedNotifier,
builder: (context, value, child) {
return IgnorePointer(
ignoring: value && clearBarrierImmediately,
child: super.buildModalBarrier(),
);
},
);
}
@override
@mustCallSuper
bool didPop(T? result) {
_poppedNotifier.value = true;
_updateSnapshotState();
return super.didPop(result);
}
void _handleDragStart(
BuildContext context,
) {
_isUserDragging = true;
_updateSnapshotState();
if (callNavigatorUserGestureMethods) {
navigator?.didStartUserGesture();
}
}
void _handleDragUpdate(BuildContext context, double delta, bool wouldScroll) {
if (_poppedNotifier.value) return;
final currentValue = controller?.value ?? 0;
var adjustedDelta = delta;
final maxExtent = effectiveSnappingConfig.maxExtent;
final applyResistance = !draggable || currentValue > maxExtent;
if (wouldScroll && (currentValue - delta) > maxExtent) {
// If the scrollable would scroll, and the sheet will be dragged past its
// max, we don't allow that.
adjustedDelta = currentValue - maxExtent;
} else if (applyResistance && delta != 0) {
final stickingPoint = _stickingPoint ?? 1.0;
// When dragging up past fully open, reduce the delta with diminishing
// returns
final overshoot = (stickingPoint - currentValue).abs();
final resistance = 1.0 /
(1.0 + overshoot * overshootResistance); // Exponential resistance
adjustedDelta = delta * resistance;
}
final newValue = currentValue - adjustedDelta;
controller?.value = newValue;
_animationTargetValue = newValue;
}
void _handleDragEnd(
BuildContext context,
double velocity,
bool willScroll,
) {
_isUserDragging = false;
final currentValue = controller!.value;
if (callNavigatorUserGestureMethods) {
navigator?.didStopUserGesture();
}
// If the route has been popped, don't interfere with the closing animation
if (_poppedNotifier.value) return;
_dragEndVelocity = velocity;
final maxExtent = effectiveSnappingConfig.maxExtent;
// If dragged past fully open, always snap back to 1.0
if (currentValue > maxExtent || !draggable) {
final stickingPoint = _stickingPoint ?? maxExtent;
// Scale the velocity by the same resistance factor that was applied
//during dragging
final overshoot = (currentValue - stickingPoint).abs();
final resistance = 1.0 / (maxExtent + overshoot * overshootResistance);
final adjustedVelocity = velocity * resistance;
final backSim = motion.createSimulation(
start: currentValue,
end: maxExtent,
velocity: -adjustedVelocity,
);
controller!.animateWith(backSim);
_dragEndVelocity = null;
} else {
// Find the target snap point based on position and velocity
final targetValue =
_animationTargetValue = effectiveSnappingConfig.findTargetSnapPoint(
currentValue,
velocity,
);
// If target is 0 (closed), dismiss the sheet
if (targetValue <= 0.001) {
navigator?.pop();
} else {
// Animate to the target snap point
final snapSim = motion.createSimulation(
start: currentValue,
end: targetValue,
velocity: -_dragEndVelocity!,
);
controller!.animateWith(snapSim);
_dragEndVelocity = null;
}
}
_updateSnapshotState();
}
/// Wraps [child] in a [SnapshotWidget] if another sheet route with
/// snapshotting is stacked on top of this one.
///
/// Call this in [buildTransitions] around the content that should be
/// snapshotted when covered by another sheet. Returns [child] unchanged
/// when no snapshotting is active.
@protected
Widget maybeSnapshotChild(Widget child) {
final controller = _coveredBySnapshotController;
if (controller == null) return child;
return SnapshotWidget(
controller: controller,
mode: SnapshotMode.permissive,
autoresize: true,
child: child,
);
}
@override
@mustCallSuper
void didChangeNext(Route<dynamic>? nextRoute) {
super.didChangeNext(nextRoute);
if (nextRoute is StupidSimpleSheetTransitionMixin) {
_coveredBySnapshotController = nextRoute._backgroundSnapshotController;
} else {
_coveredBySnapshotController = null;
}
}
@override
@mustCallSuper
void didPopNext(Route<dynamic> nextRoute) {
super.didPopNext(nextRoute);
_coveredBySnapshotController = null;
}
@override
@mustCallSuper
void dispose() {
_backgroundSnapshotController.dispose();
_poppedNotifier.dispose();
super.dispose();
}
}
/// A mixin that provides imperative control over a sheet's animation.
///
/// Mix this into your [PopupRoute] that also uses
/// [StupidSimpleSheetTransitionMixin] to allow children of the sheet to
/// imperatively control the sheet's position.
mixin StupidSimpleSheetController<T> on StupidSimpleSheetTransitionMixin<T> {
/// Retrieves the current [StupidSimpleSheetController] from the given
/// [BuildContext].
///
/// This will only work if called from a context that is inside the
/// [StupidSimpleSheetRoute].
static StupidSimpleSheetController<T>? maybeOf<T>(BuildContext context) {
final route = ModalRoute.of(context);
if (route case final StupidSimpleSheetController<T> route) {
return route;
}
return null;
}
/// Can be used to imperatively animate the sheet to a relative position, but
/// can't close it.
///
/// The [relativePosition] must be larger than 0.0 (fully closed) and
/// lower than or equal to 1.0 (fully open).
///
/// If [snap] is true, the sheet will snap to the nearest snapping point
/// after reaching the target position.
///
/// If you want to close the sheet, use [Navigator.pop] instead.
TickerFuture animateToRelative(double relativePosition, {bool snap = false}) {
assert(
relativePosition > 0.0 && relativePosition <= 1.0,
'Relative position must be larger than 0.0 and less than or equal to 1.0',
);
assert(
controller != null,
'Controller is null. Make sure the route is pushed before calling.',
);
// We only animate if this route is still current
if (!isCurrent) return TickerFuture.complete();
final double target;
if (snap) {
// Find the closest snapping point that isn't 0.0
final config = effectiveSnappingConfig;
target = switch (config.findTargetSnapPoint(relativePosition, 0)) {
0.0 => config.points.first,
final v => v,
};
} else {
target = relativePosition;
}
final simulation = motion.createSimulation(
start: controller!.value,
end: target,
velocity: controller!.velocity,
);
_animationTargetValue = target;
return controller!.animateWith(simulation);
}
/// Updates the snapping configuration to an override for the sheet.
///
/// Pass `null` to [newConfig] to reset the configuration to the one
/// originally passed to the route constructor.
///
/// If [animateToComply] is `true`, the sheet will immediately animate to
/// comply with the new snapping configuration. This will snap the sheet to
/// the nearest valid snapping point in the new configuration.
///
/// Example:
/// ```dart
/// // Update to a new configuration and animate to comply
/// controller.updateSheetSnappingConfig(
/// SheetSnappingConfig([0.3, 0.6, 1.0]),
/// animateToComply: true,
/// );
///
/// // Reset to the original configuration
/// controller.updateSheetSnappingConfig(null);
/// ```
TickerFuture overrideSnappingConfig(
SheetSnappingConfig? newConfig, {
bool animateToComply = false,
}) {
assert(
controller != null,
'Controller is null. Make sure the route is pushed before calling.',
);
_snappingConfigOverride = newConfig;
// Only animate to comply if requested and if this route is still current
if (animateToComply && isCurrent) {
final currentPosition = controller!.value;
// Find the nearest snapping point in the new configuration
final targetPosition = effectiveSnappingConfig.findTargetSnapPoint(
currentPosition,
controller!.velocity,
includeClosed: false,
);
// If the current position is already at a valid snap point, don't animate
if ((targetPosition - currentPosition).abs() < 0.001) {
return TickerFuture.complete();
}
final simulation = motion.createSimulation(
start: currentPosition,
end: targetPosition,
velocity: controller!.velocity,
);
_animationTargetValue = targetPosition;
return controller!.animateWith(simulation);
}
return TickerFuture.complete();
}
}

View File

@@ -0,0 +1,28 @@
name: stupid_simple_sheet
description: A simple sheet widget for Flutter.
version: 0.9.1-ohos.1
resolution: workspace
repository: https://github.com/whynotmake-it/rivership/tree/main/packages/stupid_simple_sheet
homepage: https://whynotmake.it
topics: [animation, motion, physics, spring, curve]
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.10.0"
dependencies:
flutter:
sdk: flutter
meta: ">=1.15.0 <2.0.0"
motor: ^1.1.0
scroll_drag_detector: ^0.1.0+2
dev_dependencies:
flutter_test:
sdk: flutter
lintervention: ^0.3.1
mocktail: ^1.0.3
snaptest: ^0.3.0
spot: ^0.18.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
void main() {
const motion = CupertinoMotion.smooth(
duration: Duration(milliseconds: 300),
snapToEnd: true,
);
Widget buildApp({
required RouteSnapshotMode mode,
SheetSnappingConfig snappingConfig = SheetSnappingConfig.full,
bool enableSecondSheet = false,
}) {
return MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
key: const ValueKey('open'),
onPressed: () => Navigator.of(context).push(
StupidSimpleGlassSheetRoute<void>(
motion: motion,
backgroundSnapshotMode: mode,
snappingConfig: snappingConfig,
child: Builder(
builder: (context) => Scaffold(
key: const ValueKey('sheet'),
body: enableSecondSheet
? Center(
child: TextButton(
key: const ValueKey('open2'),
onPressed: () => Navigator.of(context).push(
StupidSimpleGlassSheetRoute<void>(
motion: motion,
backgroundSnapshotMode: mode,
child: const Scaffold(
key: ValueKey('sheet2'),
),
),
),
child: const Text('Open 2'),
),
)
: const SizedBox.expand(),
),
),
),
),
child: const Text('Open'),
),
),
),
),
);
}
/// Returns the route's [SnapshotController.allowSnapshotting] value.
bool isSnapshotting(WidgetTester tester) {
final route = ModalRoute.of(
tester.element(find.byKey(const ValueKey('sheet'))),
)! as StupidSimpleGlassSheetRoute;
// ignore: invalid_use_of_protected_member
return route.backgroundSnapshotController.allowSnapshotting;
}
group('RouteSnapshotMode', () {
testWidgets('never: no SnapshotWidget in delegated transition',
(tester) async {
await tester.pumpWidget(buildApp(mode: RouteSnapshotMode.never));
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
// With never, delegatedTransition returns null → no SnapshotWidget
expect(find.byType(SnapshotWidget), findsNothing);
});
testWidgets('always: snapshotting stays on throughout', (tester) async {
await tester.pumpWidget(buildApp(mode: RouteSnapshotMode.always));
await tester.tap(find.byKey(const ValueKey('open')));
// Mid-animation
await tester.pump(const Duration(milliseconds: 100));
expect(isSnapshotting(tester), isTrue);
// Settled
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isTrue);
});
testWidgets('openAndForward: on during open animation and when settled',
(tester) async {
await tester.pumpWidget(
buildApp(mode: RouteSnapshotMode.openAndForward),
);
await tester.tap(find.byKey(const ValueKey('open')));
// Mid open-animation
await tester.pump(const Duration(milliseconds: 100));
expect(isSnapshotting(tester), isTrue);
// Settled
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isTrue);
});
testWidgets('openAndForward: off during user drag', (tester) async {
await tester.pumpWidget(
buildApp(mode: RouteSnapshotMode.openAndForward),
);
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isTrue);
// Start drag
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const ValueKey('sheet'))),
);
await gesture.moveBy(const Offset(0, 50));
await tester.pump();
expect(isSnapshotting(tester), isFalse);
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('openAndForward: off during animation to intermediate snap',
(tester) async {
await tester.pumpWidget(
buildApp(
mode: RouteSnapshotMode.openAndForward,
// Initial snap is 0.5 (the lowest non-zero point).
// The sheet opens to 0.5, not 1.0.
snappingConfig: const SheetSnappingConfig([0.5, 1.0]),
),
);
await tester.tap(find.byKey(const ValueKey('open')));
// Mid-animation toward 0.5 — target is not max extent so no snapshot
await tester.pump(const Duration(milliseconds: 100));
expect(isSnapshotting(tester), isFalse);
// Settled at 0.5, still not the final snap point (1.0)
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isFalse);
});
testWidgets('animating: on during animation, off when settled',
(tester) async {
await tester.pumpWidget(buildApp(mode: RouteSnapshotMode.animating));
await tester.tap(find.byKey(const ValueKey('open')));
// Mid-animation
await tester.pump(const Duration(milliseconds: 100));
expect(isSnapshotting(tester), isTrue);
// Settled
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isFalse);
});
testWidgets('settled: off during animation, on when settled',
(tester) async {
await tester.pumpWidget(buildApp(mode: RouteSnapshotMode.settled));
await tester.tap(find.byKey(const ValueKey('open')));
// Mid-animation
await tester.pump(const Duration(milliseconds: 100));
expect(isSnapshotting(tester), isFalse);
// Settled
await tester.pumpAndSettle();
expect(isSnapshotting(tester), isTrue);
});
testWidgets(
'sheet-on-sheet: second sheet snapshots the first via '
'maybeSnapshotChild', (tester) async {
await tester.pumpWidget(
buildApp(
mode: RouteSnapshotMode.always,
enableSecondSheet: true,
),
);
// Open first sheet
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
// No SnapshotWidget wrapping the first sheet's own content yet
// (maybeSnapshotChild returns child as-is when not covered)
final snapshotsBefore =
tester.widgetList<SnapshotWidget>(find.byType(SnapshotWidget));
final countBefore = snapshotsBefore.length;
// Open second sheet on top
await tester.tap(find.byKey(const ValueKey('open2')));
await tester.pumpAndSettle();
// Now there should be an additional SnapshotWidget from
// maybeSnapshotChild wrapping the first sheet's content
final snapshotsAfter =
tester.widgetList<SnapshotWidget>(find.byType(SnapshotWidget));
expect(snapshotsAfter.length, greaterThan(countBefore));
});
});
}

File diff suppressed because it is too large Load Diff