Use onPan GestureDetector inside a SingleChildScrollView - flutter

Problem
So I have a GestureDetector widget inside SingleChildScrollView (The scroll view is to accommodate smaller screens). The GestureDetector is listening for pan updates.
When the SingleChildScrollView is "in use" the GestureDetector cannot receive pan updates as the "dragging" input from the user is forwarded to the SingleChildScrollView.
What I want
Make the child GestureDetector have priority over the SingleChildScrollView when dragging on top of the GestureDetector -- but still have functionality of scrolling SingleChildScrollView outside GestureDetector.
Example
If you copy/paste this code into dart pad you can see what I mean. When the gradient container is large the SingleChildScrollView is not active -- you are able to drag the blue box and see the updates in the console. However, once you press the switch button the container becomes smaller and the SingleChildScrollView becomes active. You are now no longer able to get pan updates in the console only able to scroll the container.
Sidenote: It seems that if you drag on the blue box quickly you are able to get drag updates but slowly dragging it just scrolls the container. I'm not sure if that's a bug or a feature but I'm not able to reproduce the same result in my production app.
import 'package:flutter/material.dart';
final Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: MyWidget(),
),
),
);
}
}
class MyWidget extends StatefulWidget {
const MyWidget({Key? key}) : super(key: key);
#override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
bool enabled = false;
#override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: enabled ? 200 : 400,
child: SingleChildScrollView(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment(0.8, 0.0),
colors: <Color>[Color(0xffee0000), Color(0xffeeee00)],
tileMode: TileMode.repeated,
),
),
height: 400,
width: 200,
child: Center(
child: GestureDetector(
onPanUpdate: (details) => print(details),
child: Container(
height: 100,
width: 100,
color: Colors.blue,
child: Center(
child: Text(
"Drag\nme",
textAlign: TextAlign.center,
),
),
),
),
),
),
),
),
ElevatedButton(
onPressed: () => setState(() {
enabled = !enabled;
}),
child: Text("Switch"))
],
);
}
}

As #PatrickMahomes said this answer (by #Chris) will solve the problem. However, it will only check if the drag is in line with the GestureDetector. So a full solution would be this:
bool _dragOverMap = false;
GlobalKey _pointerKey = new GlobalKey();
_checkDrag(Offset position, bool up) {
if (!up) {
// find your widget
RenderBox box = _pointerKey.currentContext.findRenderObject();
//get offset
Offset boxOffset = box.localToGlobal(Offset.zero);
// check if your pointerdown event is inside the widget (you could do the same for the width, in this case I just used the height)
if (position.dy > boxOffset.dy &&
position.dy < boxOffset.dy + box.size.height) {
// check x dimension aswell
if (position.dx > boxOffset.dx &&
position.dx < boxOffset.dx + box.size.width) {
setState(() {
_dragOverMap = true;
});
}
}
} else {
setState(() {
_dragOverMap = false;
});
}
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Scroll Test"),
),
body: new Listener(
onPointerUp: (ev) {
_checkDrag(ev.position, true);
},
onPointerDown: (ev) {
_checkDrag(ev.position, false);
},
child: ListView(
// if dragging over your widget, disable scroll, otherwise allow scrolling
physics:
_dragOverMap ? NeverScrollableScrollPhysics() : ScrollPhysics(),
children: [
ListTile(title: Text("Tile to scroll")),
Divider(),
ListTile(title: Text("Tile to scroll")),
Divider(),
ListTile(title: Text("Tile to scroll")),
Divider(),
// Your widget that you want to prevent to scroll the Listview
Container(
key: _pointerKey, // key for finding the widget
height: 300,
width: double.infinity,
child: FlutterMap(
// ... just as example, could be anything, in your case use the color picker widget
),
),
],
),
),
);
}
}

Related

How to make Column scrollable when overflowed but use expanded otherwise

I am trying to achieve an effect where there is expandable content on the top end of a sidebar, and other links on the bottom of the sidebar. When the content on the top expands to the point it needs to scroll, the bottom links should scroll in the same view.
Here is an example of what I am trying to do, except that it does not scroll. If I wrap a scrollable view around the column, that won't work with the spacer or expanded that is needed to keep the bottom links on bottom:
import 'package:flutter/material.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: MyWidget(),
),
),
);
}
}
class MyWidget extends StatefulWidget {
#override
State<MyWidget> createState() {
return MyWidgetState();
}
}
class MyWidgetState extends State<MyWidget> {
List<int> items = [1];
#override
Widget build(BuildContext context) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(items.last + 1);
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
if (items.length != 1) items.removeLast();
});
},
),
],
),
for (final item in items)
MyAnimatedWidget(
child: SizedBox(
height: 200,
child: Center(
child: Text('Top content item $item'),
),
),
),
Spacer(),
Container(
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all()),
height: 200,
child: Text('Bottom content'),
)
],
);
}
}
class MyAnimatedWidget extends StatefulWidget {
final Widget? child;
const MyAnimatedWidget({this.child, Key? key}) : super(key: key);
#override
State<MyAnimatedWidget> createState() {
return MyAnimatedWidgetState();
}
}
class MyAnimatedWidgetState extends State<MyAnimatedWidget>
with SingleTickerProviderStateMixin {
late AnimationController controller;
#override
initState() {
controller = AnimationController(
value: 0, duration: const Duration(seconds: 1), vsync: this);
controller.animateTo(1, curve: Curves.linear);
super.initState();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
return SizedBox(height: 200 * controller.value, child: widget.child);
});
}
}
I have tried using a global key to get the size of the spacer and detect after rebuilds whether the spacer has been sized to 0, and if so, re-build the entire widget as a list view (without the spacer) instead of a column. You also need to listen in that case for if the size shrinks and it needs to become a column again, it seemed to make the performance noticeably worse, it was tricky to save the state when switching between column/listview, and it seemed not the best way to solve the problem.
Any ideas?
Try implementing this solution I've just created without the animation you have. Is a scrollable area at the top and a persistent footer.
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
home: SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("My AppBar"),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// Your scrollable widgets here
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.blue,
),
Container(
height: 100,
color: Colors.red,
),
],
),
),
),
Container(
child: Text(
'Your footer',
),
color: Colors.blueGrey,
height: 200,
width: double.infinity,
)
],
),
),
),
);
}
}

Flutter: Override 'PageView' scroll gesture with child's GestureDetector

I am using a combination of "BottomNavigationBar" and "PageView" for navigation in my app. The user can either swipe to the next page or use the navigation bar.
On one of my pages, I would like to use a gesture detector that handles pan gestures, both vertically and horizontally.
I can't find a way to override the PageView's gesture detection with the nested GestureDetector. This means only vertical pan gestures are handled, as the horizontal ones are occupied by the PageView.
How can I disable / override the PageViews gesture detection for only that page or only the widget, without completely disabling the PageViews scroll physics?
I have created a simplified version of my App to isolate the issue, and attached a video of the problem below.
Any help would be greatly appreciated!
Here is the code inside my main.dart:
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: GestureIssueExample(),
);
}
}
class GestureIssueExample extends StatefulWidget {
GestureIssueExample({Key key}) : super(key: key);
#override
_GestureIssueExampleState createState() => _GestureIssueExampleState();
}
class _GestureIssueExampleState extends State<GestureIssueExample> {
int _navigationIndex;
double _xLocalValue;
double _yLocalValue;
PageController _pageController;
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: null,
bottomNavigationBar: _buildBottomNavigationBar(),
backgroundColor: Colors.white,
body: PageView(
controller: _pageController,
onPageChanged: _onNavigationPageChanged,
children: [
//Just a placeholder to represent a page to the left of the "swipe cards" widget
_buildSamplePage("Home"),
//Center child of 'PageView', contains a GestureDetector that handles Pan Gestures
//Thanks to the page view however, only vertical pan gestures are detected, while both horizontal and vertical gestures
//need to be handled...
Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Local X: ${_xLocalValue.toString()}\nLocal Y: ${_yLocalValue.toString()}"),
GestureDetector(
onPanStart: (details) => setState(
() {
this._xLocalValue = details.localPosition.dx;
this._yLocalValue = details.localPosition.dy;
},
),
onPanUpdate: (details) => setState(
() {
this._xLocalValue = details.localPosition.dx;
this._yLocalValue = details.localPosition.dy;
},
),
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: 100.0,
color: Colors.red,
alignment: Alignment.center,
child: Text("Slidable Surface",
style: TextStyle(color: Colors.white)),
),
),
],
),
),
//Just a placeholder to represent a page to the right of the "swipe cards" widget
_buildSamplePage("Settings"),
],
),
);
}
#override
void initState() {
super.initState();
this._navigationIndex = 0;
this._pageController = PageController(
initialPage: _navigationIndex,
);
}
Widget _buildSamplePage(String text) {
// This simply returns a container that fills the page,
// with a text widget in its center.
return Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
color: Colors.grey[900],
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: Colors.white, fontSize: 30.0, fontWeight: FontWeight.bold),
),
);
}
Widget _buildBottomNavigationBar() {
//Returns the bottom navigation bar for the scaffold
return BottomNavigationBar(
backgroundColor: Colors.grey[900],
selectedItemColor: Colors.redAccent,
unselectedItemColor: Colors.white,
items: [
BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: "Home"),
BottomNavigationBarItem(
icon: Icon(Icons.check_box_outline_blank), label: "Cards"),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined), label: "Settings"),
],
currentIndex: _navigationIndex,
onTap: _onNavigationPageChanged,
);
}
void _onNavigationPageChanged(int newIndex) {
//Set the new navigation index for the nav bar
setState(() => this._navigationIndex = newIndex);
//Animate to the selected page
_pageController.animateToPage(
newIndex,
curve: Curves.easeInOut,
duration: Duration(microseconds: 100),
);
}
}
Can you try something like this:
Add this line to your PageView:
PageView(
...
physics: _navigationIndex == 1 ? NeverScrollableScrollPhysics() : AlwaysScrollableScrollPhysics(),
...
)
Note: the number 1 is because the page with the GestureDetector is on index 1.

flutter - chat's input box keyboard pushing message-list upwards

I have a fairly simple flutter app. It has a chat feature.
However, I have a problem with the chat feature.
It's made up of a widget does Scaffold and in it SingleChildScrollView - which has a message-list (container) and input-area (container). Code is attached.
Problem is: if I click on the input box, the keyboard opens and it pushes the message-list.
Pushing the message-list is an acceptable thing if you are already at the bottom of the message-list.
However, if the user scrolled up and saw some old messages, I don't want the message-list widget to be pushed up.
Also, I don't want the message-list to be pushed up if I have only a handful of messages (because that just makes the messages disappear when keyboard opens, and then I need to go and scroll to the messages that have been pushed [user is left with 0 visible messages until they scroll]).
I tried different approaches - like
resizeToAvoidBottomInset: false
But nothing seems to work for me, and this seems like it should be a straightforward behavior (for example, whatsapp act like the desired behavior).
My only option I fear is to listen to keyboard opening event, but I was hoping for a more elegant solution.
Here's my code:
#override
Widget build(BuildContext context) {
height = MediaQuery.of(context).size.height;
width = MediaQuery.of(context).size.width;
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: <Widget>[
SizedBox(height: height * 0.1),
buildMessageList(), // container
buildInputArea(context), // container
],
),
),
);
Widget buildInputArea(BuildContext context) {
return Container(
height: height * 0.1,
width: width,
child: Row(
children: <Widget>[
buildChatInput(),
buildSendButton(context),
],
),
);
}
Widget buildMessageList() {
return Container(
height: height * 0.8,
width: width,
child: ListView.builder(
controller: scrollController,
itemCount: messages.length,
itemBuilder: (BuildContext context, int index) {
return buildSingleMessage(index);
},
),
);
}
This seems to work for me:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
#override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ScrollController _controller = ScrollController();
#override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIOverlays([]);
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(
body: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
buildMessageList(),
buildInputArea(context),
],
),
),
);
}
Widget buildInputArea(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: TextField(),
),
RaisedButton(
onPressed: null,
child: Icon(Icons.send),
),
],
);
}
Widget buildMessageList() {
return Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: 50,
controller: _controller,
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: EdgeInsets.all(10),
child: Container(
color: Colors.red,
height: 20,
child: Text(index.toString()),
),
);
},
),
);
}
}
I think the problem is that you are using fixed sizes for all widgets. In this case it is better to use Expanded for the ListView and removing the SingleChildScrollView. That way the whole Column won't scroll, but only the ListView.
Try to use Stack:
#override
Widget build(BuildContext context) {
height = MediaQuery.of(context).size.height;
width = MediaQuery.of(context).size.width;
return Scaffold(
body: Stack(
children: <Widget>[
SingleChildScrollView(
child: Column(
children: <Widget>[
SizedBox(height: height * 0.1),
buildMessageList(),
],
),
),
Positioned(
bottom: 0.0,
child: buildInputArea(context),
),
],
),
);
}
Setting resizeToAvoidBottomInset property to false in your Scaffold should work.
You can use NotificationListener to listen to scroll notifications to detect that user is at the bottom of the message-list. If you are at the bottom you can then set resizeToAvoidBottomInset to true.
Something like this should work
final resizeToAvoidBottomInset = true;
_onScrollNotification (BuildContext context, ScrollNotification scrollNotification) {
if (scrollNotification is ScrollUpdateNotification) {
// detect scroll position here
// and set resizeToAvoidBottomInset to false if needed
}
}
#override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: this.resizeToAvoidBottomInset,
body: NotificationListener<ScrollNotification>(
onNotification: (scrollNotification) {
return _onScrollNotification(context, scrollNotification);
},
child: SingleChildScrollView(
child: Column(
children: <Widget>[
buildMessageList(), // container
buildInputArea(context), // container
],
),
),
),
);
}
this is technically already answered, and the answer is almost correct. However, I have found a better solution to this. Previously the author mentions that he wants to have a similar experience to WhatsApp. By using the previous solution, the listview would not be able to scrolldown to maxExtent when the sent button is pressed. To fix this I implemented Flex instead of Expanded, and use a singlechildscrollview for the input area
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
#override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ScrollController _controller = ScrollController();
TextEditingController _textcontroller=TextEditingController();
List<String> messages=[];
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(
body: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: messages.length,
controller: _controller,
itemBuilder: (BuildContext context, int index) {
print("From listviewbuilder: ${messages[index]}");
return Padding(
padding: EdgeInsets.all(10),
child: Container(
color: Colors.red,
height: 20,
child: Text(messages[index])
),
);
},
),
),
SingleChildScrollView(
child: Row(
children: <Widget>[
Expanded(
child: TextField(controller: _textcontroller),
),
RaisedButton(
onPressed: (){
Timer(Duration(milliseconds: 100), () {
_controller.animateTo(
_controller.position.maxScrollExtent,
//scroll the listview to the very bottom everytime the user inputs a message
curve: Curves.easeOut,
duration: const Duration(milliseconds: 300),
);
});
setState(() {
messages.add(_textcontroller.text);
});
print(messages);
},
child: Icon(Icons.send),
),
],
),
),
],
),
),
);
}
}
It's better to use flex because expanded as the documentation says, expands over available space, whereas flex would resize to the appropriate proportion. This way if you are going for the "WhatsApp experience" in which the listview scrolls down once you sent a message. The listview would resize when the keyboard pops up and you will get to the bottom, instead of it not going fully to the bottom.

How to receive both onTapDown (eagerly) and onDoubleTap?

I'd like to perform some interaction:
when an initial touch (onTapDown) happens (e.g. clear existing selections), AND
when a double tap happens (onDoubleTap) happens (e.g. highlight a new selection)
Using a GestureDetector:
if only register a onTapDown, the callback is called immediately at the first touch
if I register both onTapDown and onDoubleTap, and the user performs a simple tap, it takes some considerable time until the onTapDown event is called (1/2 a second?). If I update the display following the tap, for the user, this feels like rendering jank -- but it in fact just getting the event to late.
Is there a way to eagerly receive onTapDown AND onDoubleTap? (Its OK, in fact preferred, if I get 2 events on a double tap).
import 'package:flutter/material.dart';
// 1. double tap the green box
// 2. single tap the green box again and that it takes a long
// time to update the text
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) => MaterialApp(
title: 'Flutter Playground',
home: Material(child: SafeArea(child: content())));
Widget content() {
final controller = TextEditingController(text: "-");
return Center(
child: Column(children: [
GestureDetector(
onTapDown: (_) {
controller.text = "tap down";
// print("[${DateTime.now().millisecondsSinceEpoch}] tap down");
},
onDoubleTap: () {
controller.text = "double tap ";
// print("[${DateTime.now().millisecondsSinceEpoch}] double tap");
},
child: Container(
color: Colors.green,
width: 200,
height: 200,
child: Center(child: Text("Tap me")))),
Container(
color: Colors.red,
width: 200,
height: 200,
child: Center(child: TextField(controller: controller)))
]));
}
}
main() => runApp(MyApp());
A hacky way to do it I suppose, but you can use a Listener for onPointerDown. The code below should log tap down twice when you double-tap:
import 'package:flutter/material.dart';
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) => MaterialApp(title: 'Flutter Playground', home: Material(child: SafeArea(child: content())));
Widget content() {
final controller = TextEditingController(text: "-");
return Center(
child: Column(
children: [
GestureDetector(
onDoubleTap: () {
controller.text = "double tap";
print("double tap");
},
child: Listener(
onPointerDown: (_) {
controller.text = "tap down";
print("tap down");
},
child: Container(
color: Colors.green,
width: 200,
height: 200,
child: Center(
child: Text("Tap me"),
),
),
)),
Container(
color: Colors.red,
width: 200,
height: 200,
child: Center(
child: TextField(controller: controller),
),
)
],
),
);
}
}
main() => runApp(MyApp());
Use gesture_x_detector plugin.
XGestureDetector(
bypassTapEventOnDoubleTap: true,
doubleTapTimeConsider: 170
child: ...
)

Flutter CustomScrollView slivers stacking

I am trying to create a scrollView using CustomScrollView.
The effect that I need, is very similar to this one.
I need the SliverList to be stacked above the SliverAppbar, without the list taking the whole screen and hiding the SliverAppbar.
The reason I want to do this, is that i need to attach a persistent Positioned widget on top of that list, and it won't appear unless the list is stacked above the SliverAppbar.
Here's my code.
Step one:
Use ListView inside SliverAppBar widget. To make css overflow:hidden effect.
Step two:
Add controller to NestedScrollView and move the button on scrolling in a stack. Plus calculate where you want to stop button moving.
class MyHomePage extends StatefulWidget {
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
ScrollController scrollController;
final double expandedHight = 150.0;
#override
void initState() {
super.initState();
scrollController = new ScrollController();
scrollController.addListener(() => setState(() {}));
}
#override
void dispose() {
scrollController.dispose();
super.dispose();
}
double get top {
double res = expandedHight;
if (scrollController.hasClients) {
double offset = scrollController.offset;
if (offset < (res - kToolbarHeight)) {
res -= offset;
} else {
res = kToolbarHeight;
}
}
return res;
}
#override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
NestedScrollView(
controller: scrollController,
headerSliverBuilder: (context, value) {
return [
SliverAppBar(
pinned: true,
expandedHeight: expandedHight,
flexibleSpace: ListView(
physics: const NeverScrollableScrollPhysics(),
children: [
AppBar(
title: Text('AfroJack'),
elevation: 0.0,
),
Container(
color: Colors.blue,
height: 100,
alignment: Alignment.center,
child: RaisedButton(
child: Text('folow'),
onPressed: () => print('folow pressed'),
),
),
],
),
),
];
},
body: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: 80,
itemBuilder: (BuildContext context, int index) {
return Text(
'text_string'.toUpperCase(),
style: TextStyle(
color: Colors.white,
),
);
},
),
),
Positioned(
top: top,
width: MediaQuery.of(context).size.width,
child: Align(
child: RaisedButton(
onPressed: () => print('shuffle pressed'),
child: Text('Suffle'),
),
),
),
],
),
);
}
}