Flutter: Custom widget with PageView physics and interactions - flutter

I need to make a screen on which the interaction is similar to PageView, but with something very different on the screen. I don't want to deal with separate pages. I only need the current scroll position to place my widgets as I desire.
I succeeded in achieving this by placing PageView with empty pages at the bottom of the Stack and placing my widgets inside AnimatedBuilder above PageView. The only problem is that my widgets have their own GestureDetectors and that's why PageView doesn't scroll when I start scrolling gestures over my widget.
Here is a sample. I need the same behavior but without scroll problems when I start dragging over my top widgets.
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
#override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _pageController = PageController(viewportFraction: 0.7, initialPage: 5);
#override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
PageView.builder(
controller: _pageController,
itemBuilder: (context, index) => Container(),
itemCount: 10,
),
for (double angleOffset in [0.25, 0.5, 0.75, 1])
AnimatedBuilder(
animation: _pageController,
builder: (context, child) {
return Center(
child: Transform.rotate(
angle: (_pageController.page ?? 5) + pi * angleOffset,
child: Transform.translate(
offset: const Offset(0, 150),
child: const ColorContainer(),
),
),
);
},
),
],
),
);
}
}
class ColorContainer extends StatefulWidget {
const ColorContainer({Key? key}) : super(key: key);
#override
State<ColorContainer> createState() => _ColorContainerState();
}
class _ColorContainerState extends State<ColorContainer> {
double hue = 30;
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => hue = (hue + 80.0) % 360),
child: Container(
width: 100,
height: 100,
decoration: ShapeDecoration(
shape: const CircleBorder(),
color: HSLColor.fromAHSL(1, hue, 1, 0.5).toColor(),
),
),
);
}
}

Related

AnimatedSize not tweening unless if there's parent widget

I observe there is no tweening unless if an AnimatedSize widget has a parent Container. In the below code, the square goes to size zero if you tap on the square. If I remove the AnimatedSize widget's parent, the widget immediately goes to size zero without tweening. Furthermore, there is no tweening if I keep the parent Container but remove the color field or make it transparent.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
#override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: MyStatefulWidget(),
),
),
);
}
}
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({super.key});
#override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
double _size = 200.0;
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() {
_size = 0;
}),
child: Container( // No tweening if this widget is removed
color: Colors.red, // No tweening if this field is removed or made transparent
child: AnimatedSize(
curve: Curves.easeIn,
duration: const Duration(seconds: 1),
child: Container(
width: _size,
height: _size,
color: Colors.red,
)),
),
);
}
}
Why is the parent widget needed? Ideally I would like the tweening to happen without the need of this parent.
With TweenAnimationBuilder I didn't need the outer widget::
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
#override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: MyStatefulWidget(),
),
),
);
}
}
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({super.key});
#override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
double _endSize = 200.0;
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() {
_endSize = 0;
}),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 200.0, end: _endSize),
curve: Curves.easeIn,
duration: const Duration(seconds: 1),
builder: (context, size, child) {
return Container(
width: size,
height: size,
color: Colors.red,
);
}),
);
}
}

How to make nested NestedScrollViews work?

When trying to use the NestedScrollView with a ListView inside a different NestedScrollView Flutter throws a stack overflow error:
════════ Exception caught by widgets library ═══════════════════════════════════
The following StackOverflowError was thrown building PrimaryScrollController(no controller):
Stack Overflow
Here's a minimal-ish code where it happens:
import 'package:flutter/material.dart';
void main() async {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return const NestedScrollView1();
}
}
class NestedScrollView1 extends StatelessWidget {
const NestedScrollView1({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return MaterialApp(
home: NestedScrollView(
physics: const ClampingScrollPhysics(),
headerSliverBuilder: (_, __) => [
SliverToBoxAdapter(
child: Container(
color: Colors.blue,
height: 100,
),
)
],
body: NestedScrollView2(),
),
);
}
}
class NestedScrollView2 extends StatelessWidget {
final ScrollController scrollController = ScrollController();
NestedScrollView2({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return NestedScrollView(
controller: PrimaryScrollController.of(context),
physics: const ClampingScrollPhysics(),
headerSliverBuilder: (ctx, __) => [
SliverToBoxAdapter(
child: Container(
color: Colors.red,
height: 100,
),
),
],
body: const ListOfItems(),
);
}
}
class ListOfItems extends StatelessWidget {
const ListOfItems({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
// controller: PrimaryScrollController.of(context),
children: [
Container(color: Colors.green, height: 200),
Container(color: Colors.yellow, height: 200),
Container(color: Colors.green, height: 200),
Container(color: Colors.yellow, height: 200),
Container(color: Colors.green, height: 200),
Container(color: Colors.yellow, height: 200),
Container(color: Colors.green, height: 200),
Container(color: Colors.yellow, height: 200),
],
);
}
}
if you uncomment the controller line in ListView - it throws a stack overflow like this:
════════ Exception caught by widgets library ═══════════════════════════════════
The following StackOverflowError was thrown building ListView(scrollDirection: vertical, _NestedScrollController#81c19(inner, one client, offset 0.0), ClampingScrollPhysics, dependencies: [MediaQuery]):
Stack Overflow
The relevant error-causing widget was
ListView
Thing is on my project I have a page with a TabBarView and one of its sections has a TabBarView of it's own, and I wanted to use the NestedScrollView's to hold the tabs inside headerSliverBuilder's. Is there any way to go around this, without telling designer to reconsider the page UI or building complex custom scroll logic?
Edit: for clarity, adding a draw.io screenshot of the layout I'm trying to achieve (cannot put images right into the posts yet, ugh).
(Forgot to post an answer, better late than never I hope)
I have managed to achieve what I needed in a hack-ish solution from my colleague of just using CustomScrollView with the nested TabBar and ListView inside of SliverFillRemaining:
import 'package:flutter/material.dart';
void main() async {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return const MaterialApp(
home: OuterTabView(),
);
}
}
class OuterTabView extends StatefulWidget {
const OuterTabView({Key? key}) : super(key: key);
#override
State<OuterTabView> createState() => _OuterTabViewState();
}
class _OuterTabViewState extends State<OuterTabView> with TickerProviderStateMixin {
late TabController _tabControllerOut;
#override
void initState() {
super.initState();
_tabControllerOut = TabController(length: 3, vsync: this);
}
#override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (_, __) {
return <Widget>[
SliverToBoxAdapter(
child: TabBar(
tabs: const [
SizedBox(height: 40),
SizedBox(height: 40),
SizedBox(height: 40),
],
controller: _tabControllerOut,
),
),
];
},
body: TabBarView(
controller: _tabControllerOut,
children: const [
Tab1WithNestedTabView(),
Tab2(),
Tab3(),
],
),
),
),
);
}
}
class Tab1WithNestedTabView extends StatefulWidget {
const Tab1WithNestedTabView({Key? key}) : super(key: key);
#override
State<Tab1WithNestedTabView> createState() => _Tab1WithNestedTabViewState();
}
class _Tab1WithNestedTabViewState extends State<Tab1WithNestedTabView> with TickerProviderStateMixin {
late TabController _tabControllerIn;
#override
void initState() {
_tabControllerIn = TabController(length: 2, vsync: this);
super.initState();
}
#override
Widget build(BuildContext context) {
return CustomScrollView(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
controller: PrimaryScrollController.of(context),
slivers: [
SliverToBoxAdapter(
child: TabBar(
controller: _tabControllerIn,
tabs: const [
SizedBox(height: 40),
SizedBox(height: 40),
],
),
),
SliverFillRemaining(
child: TabBarView(
controller: _tabControllerIn,
children: const [
ItemList(
color1: Colors.green,
color2: Colors.yellow,
),
ItemList(
color1: Colors.tealAccent,
color2: Colors.black54,
),
],
),
),
],
);
}
}
class Tab2 extends StatelessWidget {
const Tab2({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return CustomScrollView(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
controller: PrimaryScrollController.of(context),
slivers: const [
SliverFillRemaining(
child: ItemList(
color1: Colors.blue,
color2: Colors.yellow,
),
),
],
);
}
}
class Tab3 extends StatelessWidget {
const Tab3({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return const ItemList(
color1: Colors.deepOrange,
color2: Colors.pinkAccent,
);
}
}
// Sample list
class ItemList extends StatelessWidget {
final Color color1;
final Color color2;
const ItemList({
required this.color1,
required this.color2,
Key? key,
}) : super(key: key);
#override
Widget build(BuildContext context) {
return ListView.builder(
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) {
return Container(
alignment: Alignment.center,
color: index.isOdd ? color1 : color2,
height: 200,
child: Text(index.toString()),
);
},
);
}
}
I admit it's not exactly the most graceful way, but worked fine enough for me. If anyone finds a better one - I'll be happy to mark that one as an accepted answer.

PageView is causing unbounded height error

class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
#override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
void openModal() {
showModalBottomSheet<dynamic>(
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20), topRight: Radius.circular(20))),
context: context,
builder: (_) {
return Wrap(
children: [
ModalSheetContent(),
],
);
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: FloatingActionButton(
onPressed: openModal,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
);
}
}
class ModalSheetContent extends StatefulWidget {
const ModalSheetContent({Key? key}) : super(key: key);
#override
_ModalSheetContentState createState() => _ModalSheetContentState();
}
class _ModalSheetContentState extends State<ModalSheetContent> {
final PageController pageController = PageController(
initialPage: 0,
keepPage: true,
);
#override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: PageView(
controller: pageController,
children: [imageVideo(context), Pricing()],
),
);
}
I want the modalSheet to take the dynamic height according to its content and not full screen. I tried wrapping PageView with Column and Expanded but it didn't work.
Error:
Viewports expand in the cross axis to fill their container and constrain their children to match their extent in the cross axis. In this case, a horizontal viewport was given an unlimited amount of vertical space in which to expand.The relevant error-causing widget was PageView
PageView must have a height in flutter, usually it takes the biggest part of the screen but in your case you are showing it inside a bottomsheet, I assume that your bottomsheet should be almost full screen, so try to give it a height
final size = MediaQuery.of(context).size;
……
SizedBox(
height: size.height - 100;
child: PageView(…),
)
If you want that your bottom sheet takes dynamic height according to its context, simple wrap your widget into column widget and add this attribute.
Column(
mainAxisSize:MainAxisSize.min,
children:[...]
)

NestedScrollView scrolls page too much

I want to implement something like an Instagram profile page with the NestedScrollView widget. So I combine this with a TabBar widget. I have two tabs. Detail and comments. Then I have other widgets on top of the TabBar. Everything works as expected so far. But the problem starts when I scroll. Even though there is nothing inside my tabs, NestedScrollView scrolls too much, and my TabBar widget comes to the top of the screen. In Instagram, if you have no photos, you can not scroll the page. But in my application, I can scroll. And this is the behavior I want to prevent. How can I do this? I also share my codes and screenshots of the unwanted behavior.
This is the page
This is the over scroll even though there is nothing to show inside the tab bar
These are the codes:
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Home Page"),
),
body: DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder: (context, isScrolled) {
return [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.all(16),
child: const Placeholder(
fallbackHeight: 300,
fallbackWidth: double.infinity,
color: Colors.amberAccent,
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: StickyTabBarDelegate(
child: const TabBar(
labelColor: Colors.black,
tabs: [
Tab(text: "Detail"),
Tab(text: "Comments"),
],
),
),
)
];
},
body: const TabBarView(
children: [
DetailTab(),
CommentsTab(),
],
),
),
),
);
}
}
class DetailTab extends StatelessWidget {
const DetailTab({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Container();
}
}
class CommentsTab extends StatelessWidget {
const CommentsTab({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Container();
}
}
class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar child;
StickyTabBarDelegate({required this.child});
#override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
child: child,
);
}
#override
double get maxExtent => child.preferredSize.height;
#override
double get minExtent => child.preferredSize.height;
#override
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
return true;
}
}

Flutter border change in ListView moves items below

I'm using ListView with simple containers, when you press container in list view it changes selected container width and colour of border, however when this happens it also moves other containers below. What I would like to get is when you select item, change the width and colour of border and don't move items in list as well as items inside of the container.
Please use code below to reproduce the problem, thanks!
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter border demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
#override
void initState() {
super.initState();
}
List<int> values = [1, 2, 3];
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Border demo'),
centerTitle: true,
),
body: Container(
child: ListView.builder(
itemCount: values.length,
itemBuilder: (BuildContext context, int index) =>
BorderContainer(value: values[index])),
));
}
}
class BorderContainer extends StatefulWidget {
final int value;
const BorderContainer({Key key, this.value}) : super(key: key);
#override
_BorderContainerState createState() => _BorderContainerState();
}
class _BorderContainerState extends State<BorderContainer> {
bool isTapped = false;
#override
Widget build(BuildContext context) {
return Container(
// height: 95, // if we add this rest of list items will not move however it will move items inside of container
child: GestureDetector(
onTapDown: (TapDownDetails details) => setState(() {
isTapped = true;
}),
onTapUp: (TapUpDetails details) => setState(() {
isTapped = false;
}),
child: Container(
margin: EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
width: isTapped ? 2.0 : 1.0,
//when pressing it moves other items because of width change
color:
isTapped ? Colors.grey : Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: <Widget>[
Text('${widget.value}'),
Text('Another item in container number: ${widget.value}')
],
),
)),
),
);
}
}
Okay i solved it following way: create extra container with thicker border above container with smaller border and only change colour opacity.
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter border demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
#override
void initState() {
super.initState();
}
List<int> values = [1, 2, 3];
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Border demo'),
centerTitle: true,
),
body: Container(
child: ListView.builder(
itemCount: values.length,
itemBuilder: (BuildContext context, int index) =>
BorderContainer(value: values[index])),
));
}
}
class BorderContainer extends StatefulWidget {
final int value;
const BorderContainer({Key key, this.value}) : super(key: key);
#override
_BorderContainerState createState() => _BorderContainerState();
}
class _BorderContainerState extends State<BorderContainer> {
bool isTapped = false;
#override
Widget build(BuildContext context) {
var innerBorder = Border.all(
width: 1.0,
color: isTapped
? Colors.grey.withOpacity(0)
: Colors.grey.withOpacity(0.3));
var outerBorder = Border.all(
width: 3.0, color: isTapped ? Colors.grey : Colors.grey.withOpacity(0));
return Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
border: outerBorder, borderRadius: BorderRadius.circular(16)),
child: GestureDetector(
onTapDown: (TapDownDetails details) => setState(() {
isTapped = true;
}),
onTapUp: (TapUpDetails details) => setState(() {
isTapped = false;
}),
child: Container(
decoration: BoxDecoration(
border: innerBorder, borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: <Widget>[
Text('${widget.value}'),
Text('Another item in container number: ${widget.value}')
],
),
)),
),
),
);
}
}