I have a requirement where I need to scroll through a list of images and also zoom and pan them. Very similar to a pdf document viewer. So I Used a ListView to show the pages and added the ListView as child to InteractiveViewer.
After zooming in I could not scroll to the top or bottom end of the ListView.
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: InteractiveViewer(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: 10,
itemBuilder: (context, _index) {
print(_index);
return Container(
color: Colors.grey,
height: MediaQuery.of(context).size.width * 1.1,
width: MediaQuery.of(context).size.width,
padding: EdgeInsets.all(2),
child: Container(
padding: EdgeInsets.all(2),
color: Colors.white,
child: Center(
child: Text('Page index: $_index'),
),
),
);
},
),
scaleEnabled: true,
panEnabled: true,
),
);
}
I guess it might be due to the InteractiveViewer handling the scroll gesture of ListView.
Is there a way to avoid vertical gesture to be handled by InteractiveViewer?
I don't think there is a way to make the two element have their scroll behavior working together, as InteractiveViewer allows you to move after zooming in your image.
Would it fulfill your requirement to set the image to fullscreen when taping on the image to zoom ?
That way you keep the scroll handled by the ScrollView and separate the InteractiveViewer to another view.
Something like that, you wrap all of your images with the ImageDetails widget and remove your InteractiveViewer from the Scaffold:
class ImageDetails extends StatelessWidget {
final String url;
const ImageDetails({Key key, this.url}) : super(key: key);
#override
Widget build(BuildContext context) {
return GestureDetector(
child: Hero(
tag: 'tag$url',
child: NetworkImage(
imageUrl: url,
fit: BoxFit.cover,
),
),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) {
return FullScreenImage(
url: url,
);
}));
});
}
}
class FullScreenImage extends StatelessWidget {
final String url;
const FullScreenImage({Key key, this.url})
: super(key: key);
#override
Widget build(BuildContext context) {
return GestureDetector(
child: InteractiveViewer(
maxScale: 2.0,
minScale: 1.0,
child: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Hero(
tag: 'tag$url',
child: SizedBox(
width: MediaQuery.of(context).size.width,
child:
NetworkImage(imageUrl: url, fit: BoxFit.fitWidth),
),
),
),
),
),
onTap: () {
Navigator.pop(context);
},
);
}
}
Related
I am attempting to have a horizontal gallery of elements which probably extends beyond the edges of the view so it needs to be horizontally scrollable. I have this. (This is primarily for a desktop app)
The elements in the gallery should be draggable but only for vertical dragging so that the user can place them somewhere else.
I've tried various approaches including a listener. Below seems to get closest to what I need, however, the picture elements are draggable in all directions. What I would like is when the user starts dragging them in a horizontal direction, instead of them being draggable, the gesture/control passes to the parent listview. So basically user can drag horizontally to scroll, and vertically to pull elements out of the gallery.
With current code, the user can scroll the listview by clicking and dragging between the elements, it seems the gesture detector never calls the onHorizontalDragStart (or onHorizontalDragUpdate.
(I have also tried with two nested GestureDetectors, one around the listview, and one around the PictElementDisplay but that didn't seem to make much sense.)
class PictGalleryView extends StatelessWidget {
PictGalleryView({
Key? key,
required this.size,
}) : super(key: key);
final Size size;
final projectSettings = GetIt.I<ProjectSettings>();
ScrollController _scrollController = ScrollController();
#override
Widget build(BuildContext context) {
return SizedBox(
height: size.height,
width: size.width,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: projectSettings.numPictElements,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: const EdgeInsets.all(5),
child: Center(
child: GestureDetector(
onHorizontalDragStart: (details) {
dev.log('onHorizontalDragStart');
// this doesn't happen?
},
child: PictElementDisplay(
//this shouldn't be horizontally draggable but it is!
element: projectSettings.pictElementDataList[index],
size: Size(75, 60),
),
),
),
);
},
),
),
);
}
}
//...
class PictElementDisplay extends StatelessWidget {
PictElementDisplay({
Key? key,
required this.element,
required this.size,
}) : super(key: key);
final PictElementData element;
final Size size;
#override
Widget build(BuildContext context) {
return SizedBox.fromSize(
child: Draggable(
data: element,
feedback: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
color: Colors.green, //todo
),
child: Text('id: ${element.id.toString()}'),
),
child: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
color: Colors.red, //todo
),
child: Text('id: ${element.id.toString()}'),
),
),
);
}
}
(and ChatGPT doesn't seem to quite know how to do it either. :-) ). Thanks in advance.
The GestureDetector has to be inside the Draggable then it can be used to override the default behaviour. In this use-case, you can pass in the ScrollControler associated with the parent ListView as a parameter to be modified which can control the listview. If you want to use the PictElementDisplay in different contexts and the override to only apply when it appears in the gallery, you can make the widget nullable and add logic only to change the behaviour when the scroll controller is present i.e.
class PictElementDisplay extends StatelessWidget {
PictElementDisplay({
required this.element,
required this.size,
this.scrollController,
});
final PictElementData element;
final Size size;
final ScrollController? scrollController;
#override
Widget build(BuildContext context) {
Widget listChild = Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
color: Colors.red, //todo
),
child: Text('id: ${element.id.toString()}'),
);
Widget gestureWidget = GestureDetector(
onHorizontalDragUpdate: (details) {
// Pass the control to the parent ListView to handle horizontal scrolling
dev.log('onHorizontalDragUpdate ${details.toString()}');
scrollController?.jumpTo(scrollController!.offset - details.delta.dx);
},
onTap: () {
//E.g. add to currently active page without needing to drag it
dev.log('tapped ${element.id}');
},
child: listChild,
);
Widget draggableChildWidget =
(scrollController != null) ? gestureWidget : listChild;
return SizedBox.fromSize(
child: Draggable(
data: element,
feedback: Container(
height: size.height,
width: size.width,
decoration: const BoxDecoration(
color: Colors.green, //todo
),
child: Text('id: ${element.id.toString()}'),
),
child: draggableChildWidget,
),
);
}
}
and in the parent level
#override
Widget build(BuildContext context) {
return SizedBox(
height: widget.size.height,
width: widget.size.width,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: projectSettings.numPictElements,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
PictElementData element =
projectSettings.pictElementDataList[index];
Size size = Size(75, 60);
return Padding(
padding: const EdgeInsets.all(5),
child: Center(
child: PictElementDisplay(
element: element,
size: size,
// scrollController allows the widget to
// override its own horizontal dragging
scrollController: scrollController,
),
),
);
},
),
),
);
}
I would like to achieve this result but I can't get my head around it...
Image exemple
The only way I found to "fixed" my header (top) and button (bottom) was to use respectively SliverPinnedHeader (which requires the user of the CustomScrollView widget) and StickySideWidget.bottom.
As I am inside a Column widget, I am required to use the Expanded widget which make the scroll works when the list is long enough, but that also take full screen height even when I only have a few items... I'd like to find a way to remove the Expanded without breaking everything
Here is a simplified code :
import 'package:flutter/material.dart';
import 'package:shared/widgets/commons/safe_area.dart';
import 'package:sliver_tools/sliver_tools.dart';
class DemoWidget extends StatelessWidget {
DemoWidget({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ElevatedButton(
child: Text('Demo'),
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.white,
isScrollControlled: true,
context: context,
builder: (context) => DemoContent(),
))));
}
}
class DemoContent extends StatelessWidget {
DemoContent({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final items = List.filled(20, '');
final bottomSheetClosingLine = Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Center(child: Container(color: Colors.grey, height: 4, width: 64)),
);
final header = Container(
padding: const EdgeInsets.all(24),
color: Colors.blue,
child: Text('Header'));
final itemWidget = Padding(
padding: const EdgeInsets.symmetric(vertical: 16), child: Text('item'));
final button = Container(
padding: EdgeInsets.only(top: 24),
width: double.infinity,
child: ElevatedButton(onPressed: () {}, child: Text('Save')),
);
return SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.80),
padding: EdgeInsets.all(24),
width: double.infinity,
child: Column(mainAxisSize: MainAxisSize.min, children: [
bottomSheetClosingLine,
Expanded(
child: StickySideWidget.bottom( /// Custom widget
body: CustomScrollView(slivers: [
SliverPinnedHeader(child: header),
SliverToBoxAdapter(
child: Column(
children: items.map((item) => itemWidget).toList(),
))
]),
side: button,
))
])));
}
}
Edit : I would also like to avoid as much as possible to add an unnecessary library...
Try to use this Widget
draggableScrollableSheet
bottomsheet(not bottombar) which stay alway on top of entire app.if open an page it always stay on top
WidgetsApp(
debugShowCheckedModeBanner: false,
color: Colors.pink,
builder: (context, child) => Stack(
children: [
GetMaterialApp(
debugShowCheckedModeBanner: false,
color: Colors.pink,
home: Scaffold(
backgroundColor: Colors.amber,
body: Center(
child: TextButton(
onPressed: () {},
child: Text(
'data',
),
),
),
),
),
//bottomsheet
Positioned(
bottom: 0,
left: 0,
right: 0,
child: PlayerSheet(),
)
],
),
);
something like above
Create something like this as your page wrapper:
class StickyPageWrapper extends StatelessWidget {
final Widget child;
const StickyPageWrapper({Key key, this.child}) : super(key: key);
#override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
return Stack(
fit: StackFit.expand,
children: [
child,
Positioned(
left: 0,
bottom: 0,
child: Hero(
tag: 'bottom_sheet',
child: Container(
color: Colors.orange,
height: size.height / 4,
width: size.width,
),
),
)
],
);
}
}
Then use it like this for one page:
class Page1 extends StatelessWidget {
const Page1({Key key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
body: StickyPageWrapper(
child: Container(
color: Colors.white38,
child: Center(
child: ElevatedButton(
child: Text('Go to page 2'),
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => Page2()));
},
)),
)),
);
}
}
Then for the other page:
class Page2 extends StatelessWidget {
const Page2({Key key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
body: StickyPageWrapper(
child: Container(
color: Colors.white38,
child: Center(
child: ElevatedButton(
child: Text('Go back to page 1'),
onPressed: () {
Navigator.of(context).pop();
},
)),
)),
);
}
}
And so on, so you wrap your every page.
I added a Hero animation to your BottomSheet when navigating between pages: https://flutter.dev/docs/development/ui/animations/hero-animations so that it looks bit nicer and more natural.
Hope this helps, let me know if I can help further.
Note: This is actually not a BottomSheet widget if that was your idea, because typically that one is created implicitly by ScaffoldState.showBottomSheet, for persistent bottom sheets, or by showModalBottomSheet, for modal bottom sheets.
I'm making a flutter web.
I want to resize a horizontal image in a CustomScrollView.
Ex: On a product detail page, I want to place the top image size 600x400 width in the center of the layout.
Regardless of the image size, it is a problem that it expands to 900x or more horizontally.
layout:
code:
class ServiceProductDetailPage extends StatelessWidget {
final String productId;
final ServiceProductModel product;
ServiceProductDetailPage({required this.product, required this.productId});
#override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 50,
title: Text('Title'),
stretch: true,
backgroundColor: Colors.black,
),
SliverToBoxAdapter(child: _Body(context, _product)),
],
),
);
}
}
Widget _Body(BuildContext context, ServiceProductModel product) {
return Card(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(900,400)
),
child: LoadingImage(url: product.image),
),
);
}
class LoadingImage extends StatelessWidget {
const LoadingImage({
Key? key,
required this.url,
}) : super(key: key);
final String url;
#override
Widget build(BuildContext context) {
return Image.network(
url,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (BuildContext c, Object err, StackTrace? stack) {
return const Center(child: Text('error'));},
frameBuilder: (BuildContext c, Widget image, int? frame, bool sync){
if (!sync && frame == null) {
return const Center(child: CircularProgressIndicator());
}
return image;
},
);
}
}
What you are looking for is an UnconstrainedBox. As per the documentation, the UnconstrainedBox makes itself take the full width of it's parent, but it allows it's child to be of any width the child decides.
So, you can use it like this.
Widget _Body(BuildContext context, ServiceProductModel product) {
return Card(
child: UnconstrainedBox(
child: Container(
color: Colors.deepOrange,
width: 900,
height: 400,
child: LoadingImage(url: product.image),
),
),
);
}
Next, you given some properties to you Image.network that is making it expand.
So remove the following
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
And just add this
fit: BoxFit.contain,
I have given your 900 width Container a color to see that it stays at 900 despite the window being larger than 900.
So there's something I'm working on and I want to have a list of these "capsules" (rounded rectangle containers). When the user taps on any given one of them, it expands to the full screen, while the rest stay on a lower layer and don't do anything.
I'm using AnimatedContainer and GestureDetector to change their state. When there's only one, it works perfectly for what I want to do. Meanwhile, as soon as I add more in a Column, because it's a single Widget I coded inside a GestureDetector with a single boolean, they all open at the same time. And I understand that even if I code them separately, it will basically just push the surrounding ones out of the way, not open above them. How would I deal with this?
I tried searching this and couldn't find anything helpful. Hopefully the answer to this will help future projects too.
bool chatCapsuleTapped = false;
bool hasFullSize = false;
#override
Widget build(BuildContext context) {
Widget _chatCapsuleAnimation() {
return GestureDetector(
onTap: () {
setState(() {
chatCapsuleTapped = !chatCapsuleTapped;
hasFullSize = true;
});
},
child: AnimatedContainer(
width: !chatCapsuleTapped ? 350 : MediaQuery.of(context).size.width,
height: !chatCapsuleTapped ? 75 : MediaQuery.of(context).size.height,
//color: !chatCapsuleTapped ? Colors.grey.withOpacity(1) : Colors.grey,
decoration: BoxDecoration(
color: !chatCapsuleTapped ? Colors.grey.shade500 : Colors.grey.shade300,
borderRadius: !chatCapsuleTapped ? BorderRadius.circular(40) : BorderRadius.circular(0),
),
child: !chatCapsuleTapped ? Container(child: Container(),) : Container(),
duration: Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn,
),
);
}
return Scaffold(
body: Container(
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_chatCapsuleAnimation(),
],
),
),
);
}
} ```
You can use Hero:
Place each widget inside a Hero widget, assign it a tag based on the index.
Then have a Full-Screen page, which contains the bigger version of the widget, but with the same tag as of the tapped item.
Sample Grabbed from here, you can paste it in DartPad
class MyWidget extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Basic Hero Animation'),
),
body: SingleChildScrollView(
child: Column(
children: List<Widget>.generate(5, (index) {
return InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Full-Screen Page'),
),
body: Container(
child: Hero(
// TAG should be same as the tapped item's index
tag: index.toString(),
child: SizedBox(
child: Container(
color: Colors.grey[(index + 1) * 100]),
),
),
),
);
},
),
);
},
child: Hero(
// Assigning tag of item as its index in the list
tag: index.toString(),
child: Container(
height: 200, color: Colors.grey[(index + 1) * 100]),
));
}))),
);
}
}
I've put the destination page within the scope of the main file for simplicity, but you can make a seperate Widget and accept index as parameter for the Bigger Hero's tag