-
Notifications
You must be signed in to change notification settings - Fork 30.2k
DraggableScrollableSheet snap animations conflict with multiple ScrollPositions #119966
Description
Steps to Reproduce
- https://dartpad.dev/?id=65bb94d872c5f8f9d5b47b07b5944317
- Select "Snap".
- Scroll the bottom sheet using a touch interface.
Expected results:
Bottom sheet snaps to expanded or collapsed position.
For reference, here's the expected snap behavior (with the scroll controller only hooked up to the outer scrollable covering the tab bar):
https://dartpad.dev/?id=7cdb15f7b00dc440053e5c54112cbc45
Actual results:
Bottom sheet oscillates between animated and initial snapped positions before possibly eventually settling.
This seems to be due to the IdleScrollActivity on the idle scroll positions calling goBallistic(0) in response to the resizing sheet. (It may be worth noting that the inner scroll position may indeed need to adjust position, for example in https://dartpad.dev/?id=7cdb15f7b00dc440053e5c54112cbc45 if the first tab is scrolled before fully expanding.)
Ostensibly this might be something to solve using a NestedScrollView, but then we run into incompatible ScrollPosition types as the DraggableScrollableSheet's scroll controller only supports its own scroll positions while NestedScrollView attempts to attach its own subclass into the outer scroll controller:
https://dartpad.dev/?id=b7650fc7f0590af8a2885b0ea8817469
(may need to open web debug for the errors to show up)
The following TypeErrorImpl was thrown during a scheduler callback:
Expected a value of type '_DraggableScrollableSheetScrollPosition', but got one of type
'_NestedScrollPosition'
When the exception was thrown, this was the stack:
...
packages/flutter/src/widgets/draggable_scrollable_sheet.dart 747:58 <fn>
Related issue re: NestedScrollView: #64157
Code sample
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const ScrollDemo());
class ScrollDemo extends StatefulWidget {
const ScrollDemo({super.key});
@override
State<ScrollDemo> createState() => ScrollDemoState();
}
class ScrollDemoState extends State<ScrollDemo> {
bool snap = false;
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(
tabBarTheme: const TabBarTheme(labelColor: Colors.black),
),
home: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final foldPosition = min(
1.0,
(Sheet.tabBarHeight + FoldTab.pageHeight) /
constraints.maxHeight,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
InteractiveViewer(
constrained: false,
child: const GridPaper(
color: Colors.red,
child: SizedBox(
width: 2000,
height: 2000,
child: Text('Maps'),
),
),
),
const Positioned(
left: 0,
bottom: 0,
child: Text('Watermark'),
),
ElevatedButton(
onPressed: () => setState(() => snap = !snap),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: snap,
onChanged: (value) =>
setState(() => snap = value!),
),
const Text('snap'),
const SizedBox(width: 8),
],
),
),
],
),
),
ConstrainedBox(
constraints: constraints,
child: DraggableScrollableSheet(
expand: false,
snap: snap,
initialChildSize: foldPosition,
snapSizes: [foldPosition],
minChildSize: Sheet.tabBarHeight / constraints.maxHeight,
builder: (context, scrollController) => CustomScrollView(
controller: scrollController,
slivers: [
SliverFillRemaining(
child: Sheet(
scrollController: scrollController,
),
)
],
),
),
)
],
);
},
),
),
);
}
/// A scroll controller subordinate to a parent controller, which
/// [createScrollPosition]s via the parent and attaches/detaches its positions
/// from the parent. This is useful for creating nested scroll controllers
/// for widgets with scrollbars that can actuate a parent scroll controller.
class SubordinateScrollController extends ScrollController {
SubordinateScrollController(
this.parent, {
String subordinateDebugLabel = 'subordinate',
}) : super(
debugLabel: parent.debugLabel == null
? null
: '${parent.debugLabel}/$subordinateDebugLabel',
initialScrollOffset: parent.initialScrollOffset,
keepScrollOffset: parent.keepScrollOffset,
);
final ScrollController parent;
// Although some use cases might seem to be simplified if parent were made
// settable, we can't really do this because scroll positions are owned by
// Scrollables rather than the scroll controller, so the scroll view is
// responsible for transferring positions from one controller to another. If
// we were to try to do the transfer here, we would end up trying to dispose
// of positions that Scrollables may still be holding on to.
@override
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) =>
parent.createScrollPosition(physics, context, oldPosition);
@override
void attach(ScrollPosition position) {
super.attach(position);
parent.attach(position);
}
@override
void detach(ScrollPosition position) {
parent.detach(position);
super.detach(position);
}
@override
void dispose() {
for (final position in positions) {
parent.detach(position);
}
super.dispose();
}
}
class Sheet extends StatefulWidget {
static const tabBarHeight = 18.0;
const Sheet({super.key, this.scrollController});
final ScrollController? scrollController;
@override
State<Sheet> createState() => SheetState();
}
class SheetState extends State<Sheet> with SingleTickerProviderStateMixin {
late final tabController = TabController(length: 3, vsync: this);
List<ScrollController>? _scrollControllers;
void _updateScrollControllers() {
_scrollControllers = widget.scrollController == null
? null
: [
for (int i = 0; i < 3; ++i)
SubordinateScrollController(widget.scrollController!)
];
}
void _disposeScrollControllers() {
if (_scrollControllers != null) {
for (final scrollController in _scrollControllers!) {
scrollController.dispose();
}
}
}
@override
void initState() {
super.initState();
_updateScrollControllers();
}
@override
void didUpdateWidget(covariant Sheet oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.scrollController != oldWidget.scrollController) {
_disposeScrollControllers();
_updateScrollControllers();
}
}
@override
void dispose() {
_disposeScrollControllers();
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
children: [
TabBar(
controller: tabController,
tabs: const [
Text('Above/below the fold'),
Text('Scrollable content'),
Text('With a list'),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
FoldTab(scrollController: _scrollControllers?[0]),
ContentTab(scrollController: _scrollControllers?[1]),
ListTab(scrollController: _scrollControllers?[2]),
],
),
),
],
);
}
class FoldTab extends StatefulWidget {
static const pageHeight = 256.0;
const FoldTab({super.key, this.scrollController});
final ScrollController? scrollController;
@override
State<FoldTab> createState() => FoldTabState();
}
class FoldTabState extends State<FoldTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
controller: widget.scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Colors.green,
height: FoldTab.pageHeight,
child: const Text('Above the fold'),
),
Container(
color: Colors.blue,
height: FoldTab.pageHeight,
child: const Text('Below the fold'),
),
],
),
);
}
}
class ContentTab extends StatefulWidget {
const ContentTab({super.key, this.scrollController});
final ScrollController? scrollController;
@override
State<ContentTab> createState() => ContentTabState();
}
class ContentTabState extends State<ContentTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
controller: widget.scrollController,
child: Text([for (int i = 0; i < 1000; ++i) 'content'].join()),
);
}
}
class ListTab extends StatefulWidget {
const ListTab({super.key, this.scrollController});
final ScrollController? scrollController;
@override
State<ListTab> createState() => ListTabState();
}
class ListTabState extends State<ListTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(128.0),
child: Container(
alignment: Alignment.center,
color: Colors.grey,
child: const Text('Header content'),
),
),
body: ListView.builder(
controller: widget.scrollController,
itemCount: 50,
itemExtent: 48.0,
itemBuilder: (context, index) => Text('Item $index'),
),
);
}
}Logs
PS C:\Users\imagi\Documents\projects\trip_planner_aquamarine> flutter analyze
Analyzing trip_planner_aquamarine...
No issues found! (ran in 3.6s)
PS C:\Users\imagi\Documents\projects\trip_planner_aquamarine> flutter doctor -v
[!] Flutter (Channel rosswang, 3.7.0-2.0.pre.15, on Microsoft Windows [Version 10.0.22621.819], locale en-US)
• Flutter version 3.7.0-2.0.pre.15 on channel rosswang at C:\Users\imagi\flutter
! Upstream repository git@github.com:AsturaPhoenix/flutter.git is not a standard remote.
Set environment variable "FLUTTER_GIT_URL" to git@github.com:AsturaPhoenix/flutter.git to dismiss this error.
• Framework revision b189260d41 (4 weeks ago), 2023-01-03 17:20:39 -0800
• Engine revision 025aefc7af
• Dart version 2.19.0 (build 2.19.0-444.0.dev)
• DevTools version 2.20.0
• If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades.
[√] Windows Version (Installed version of Windows is version 10 or higher)
[√] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
• Android SDK at C:\Users\imagi\AppData\Local\Android\sdk
• Platform android-33, build-tools 33.0.0
• Java binary at: C:\Program Files\Android\Android Studio\jre\bin\java
• Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
• All Android licenses accepted.
[√] Chrome - develop for the web
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[!] Visual Studio - develop for Windows (Visual Studio Community 2019 16.11.16)
• Visual Studio at C:\Program Files (x86)\Microsoft Visual Studio\2019\Community
• Visual Studio Community 2019 version 16.11.32602.291
• Windows 10 SDK version 10.0.16299.0
X Visual Studio is missing necessary components. Please re-run the Visual Studio installer for the "Desktop development with C++" workload, and include these components:
MSVC v142 - VS 2019 C++ x64/x86 build tools
- If there are multiple build tool versions available, install the latest
C++ CMake tools for Windows
Windows 10 SDK
[√] Android Studio (version 2021.3)
• Android Studio at C:\Program Files\Android\Android Studio
• Flutter plugin can be installed from:
https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
https://plugins.jetbrains.com/plugin/6351-dart
• Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
[√] VS Code (version 1.74.3)
• VS Code at C:\Users\imagi\AppData\Local\Programs\Microsoft VS Code
• Flutter extension version 3.57.20221221
[√] Connected device (3 available)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [Version 10.0.22621.819]
• Chrome (web) • chrome • web-javascript • Google Chrome 109.0.5414.76
• Edge (web) • edge • web-javascript • Microsoft Edge 109.0.1518.61
[√] HTTP Host Availability
• All required HTTP hosts are available
! Doctor found issues in 2 categories.
