Flutter chat text align like Whatsapp or Telegram - flutter

I'm having trouble figuring this alignment with Flutter.
The right conversion on Whatsapp or Telegram is left-aligned but the date is on the right. If there's space available for the date it is at the end of the same line.
The 1st and 3rd chat lines can be done with Wrap() widget. But the 2nd line is not possible with Wrap() since the chat text is a separate Widget and fills the full width and doesn't allow the date widget to fit. How would you do this with Flutter?

Here's an example that you can run in DartPad that might be enough to get you started. It uses a SingleChildRenderObjectWidget for laying out the child and painting the ChatBubble's chat message as well as the message time and a dummy check mark icon.
To learn more about the RenderObject class I can recommend this video. It describes all relevant classes and methods in great depth and helped me a lot to create my first custom RenderObject.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:intl/intl.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: ExampleChatBubbles(),
),
),
);
}
}
class ChatBubble extends StatelessWidget {
final String message;
final DateTime messageTime;
final Alignment alignment;
final Icon icon;
final TextStyle textStyleMessage;
final TextStyle textStyleMessageTime;
// The available max width for the chat bubble in percent of the incoming constraints
final int maxChatBubbleWidthPercentage;
const ChatBubble({
Key? key,
required this.message,
required this.icon,
required this.alignment,
required this.messageTime,
this.maxChatBubbleWidthPercentage = 80,
this.textStyleMessage = const TextStyle(
fontSize: 11,
color: Colors.black,
),
this.textStyleMessageTime = const TextStyle(
fontSize: 11,
color: Colors.black,
),
}) : assert(
maxChatBubbleWidthPercentage <= 100 &&
maxChatBubbleWidthPercentage >= 50,
'maxChatBubbleWidthPercentage width must lie between 50 and 100%',
),
super(key: key);
#override
Widget build(BuildContext context) {
final textSpan = TextSpan(text: message, style: textStyleMessage);
final textPainter = TextPainter(
text: textSpan,
textDirection: ui.TextDirection.ltr,
);
return Align(
alignment: alignment,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 5,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.green.shade200,
),
child: InnerChatBubble(
maxChatBubbleWidthPercentage: maxChatBubbleWidthPercentage,
textPainter: textPainter,
child: Padding(
padding: const EdgeInsets.only(
left: 15,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat('hh:mm').format(messageTime),
style: textStyleMessageTime,
),
const SizedBox(
width: 5,
),
icon
],
),
),
),
),
);
}
}
// By using a SingleChildRenderObjectWidget we have full control about the whole
// layout and painting process.
class InnerChatBubble extends SingleChildRenderObjectWidget {
final TextPainter textPainter;
final int maxChatBubbleWidthPercentage;
const InnerChatBubble({
Key? key,
required this.textPainter,
required this.maxChatBubbleWidthPercentage,
Widget? child,
}) : super(key: key, child: child);
#override
RenderObject createRenderObject(BuildContext context) {
return RenderInnerChatBubble(textPainter, maxChatBubbleWidthPercentage);
}
#override
void updateRenderObject(
BuildContext context, RenderInnerChatBubble renderObject) {
renderObject
..textPainter = textPainter
..maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
}
}
class RenderInnerChatBubble extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
TextPainter _textPainter;
int _maxChatBubbleWidthPercentage;
double _lastLineHeight = 0;
RenderInnerChatBubble(
TextPainter textPainter, int maxChatBubbleWidthPercentage)
: _textPainter = textPainter,
_maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
TextPainter get textPainter => _textPainter;
set textPainter(TextPainter value) {
if (_textPainter == value) return;
_textPainter = value;
markNeedsLayout();
}
int get maxChatBubbleWidthPercentage => _maxChatBubbleWidthPercentage;
set maxChatBubbleWidthPercentage(int value) {
if (_maxChatBubbleWidthPercentage == value) return;
_maxChatBubbleWidthPercentage = value;
markNeedsLayout();
}
#override
void performLayout() {
// Layout child and calculate size
size = _performLayout(
constraints: constraints,
dry: false,
);
// Position child
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width, textPainter.height - _lastLineHeight);
}
#override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({
required BoxConstraints constraints,
required bool dry,
}) {
final BoxConstraints constraints =
this.constraints * (_maxChatBubbleWidthPercentage / 100);
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<ui.LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
_lastLineHeight = lines.last.height;
// Layout child and assign size of RenderBox
if (child != null) {
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth),
parentUsesSize: true);
childSize = child!.size;
} else {
childSize =
child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
final horizontalSpaceExceeded =
lastLineWidth + childSize.width > constraints.maxWidth;
if (horizontalSpaceExceeded) {
height += childSize.height;
_lastLineHeight = 0;
} else {
height += childSize.height - _lastLineHeight;
}
if (lines.length == 1 && !horizontalSpaceExceeded) {
width += childSize.width;
}
}
return Size(width, height);
}
#override
void paint(PaintingContext context, Offset offset) {
// Paint the chat message
textPainter.paint(context.canvas, offset);
if (child != null) {
final parentData = child!.parentData as BoxParentData;
// Paint the child (i.e. the row with the messageTime and Icon)
context.paintChild(child!, offset + parentData.offset);
}
}
}
class ExampleChatBubbles extends StatelessWidget {
// Some chat dummy data
final chatData = [
[
'Hi',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -100)),
],
[
'Helloooo?',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -60)),
],
[
'Hi James',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -58)),
],
[
'Do you want to watch the basketball game tonight? We could order some chinese food :)',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'Sounds great! Let us meet at 7 PM, okay?',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'See you later!',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -55)),
],
];
#override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView.builder(
itemCount: chatData.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 5,
),
child: ChatBubble(
icon: Icon(
Icons.check,
size: 15,
color: Colors.grey.shade700,
),
alignment: chatData[index][1] as Alignment,
message: chatData[index][0] as String,
messageTime: chatData[index][2] as DateTime,
// How much of the available width may be consumed by the ChatBubble
maxChatBubbleWidthPercentage: 75,
),
);
},
),
);
}
}

#hnnngwdlch thanks for your answer it helped me, with this you have full control over the painter. I slightly modified your code for my purposes maybe it will be useful for someone.
PD: I don't know if declaring the TextPainter inside the RenderObject has significant performance disadvantages, if someone knows please write in the comments.
class TextMessageWidget extends SingleChildRenderObjectWidget {
final String text;
final TextStyle? textStyle;
final double? spacing;
const TextMessageWidget({
Key? key,
required this.text,
this.textStyle,
this.spacing,
required Widget child,
}) : super(key: key, child: child);
#override
RenderObject createRenderObject(BuildContext context) {
return RenderTextMessageWidget(text, textStyle, spacing);
}
#override
void updateRenderObject(BuildContext context, RenderTextMessageWidget renderObject) {
renderObject
..text = text
..textStyle = textStyle
..spacing = spacing;
}
}
class RenderTextMessageWidget extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
String _text;
TextStyle? _textStyle;
double? _spacing;
// With this constants you can modify the final result
static const double _kOffset = 1.5;
static const double _kFactor = 0.8;
RenderTextMessageWidget(
String text,
TextStyle? textStyle,
double? spacing
) : _text = text, _textStyle = textStyle, _spacing = spacing;
String get text => _text;
set text(String value) {
if (_text == value) return;
_text = value;
markNeedsLayout();
}
TextStyle? get textStyle => _textStyle;
set textStyle(TextStyle? value) {
if (_textStyle == value) return;
_textStyle = value;
markNeedsLayout();
}
double? get spacing => _spacing;
set spacing(double? value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextPainter textPainter = TextPainter();
#override
void performLayout() {
size = _performLayout(constraints: constraints, dry: false);
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width,
size.height - child!.size.height / _kOffset
);
}
#override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({required BoxConstraints constraints, required bool dry}) {
textPainter = TextPainter(
text: TextSpan(text: _text, style: _textStyle),
textDirection: TextDirection.ltr
);
late final double spacing;
if(_spacing == null){
spacing = constraints.maxWidth * 0.03;
} else {
spacing = _spacing!;
}
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
if(child != null){
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth), parentUsesSize: true);
childSize = child!.size;
} else {
childSize = child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
if(lastLineWidth + spacing > constraints.maxWidth - child!.size.width) {
height += (childSize.height * _kFactor);
} else if(lines.length == 1){
width += childSize.width + spacing;
}
}
return Size(width, height);
}
#override
void paint(PaintingContext context, Offset offset) {
textPainter.paint(context.canvas, offset);
final parentData = child!.parentData as BoxParentData;
context.paintChild(child!, offset + parentData.offset);
}
}

Related

How can I achieve "child of a widget which is inside a scroll widget acting like sticky header" in Flutter?

I'm trying to find a way to implement a functionality in which, in a horizontally scrollable list, there are widgets that I will call P, (which are denoted as P1, P2 and P3 in the diagram) and their children C, (which are denoted as C1, C2 and C3). As the user scrolls the list horizontally, I want C's inside P's to act like sticky headers, until they reach the boundary of their parent.
I'm sorry if the description & diagram is not enough, I will try to clarify anything unclear.
Diagram of the problem
As I'm thinking of a way to implement this, I can't seem to find a plausible solution. Also if there is a package that can help with this issue, I would really appreciate any suggestions.
I am not sure about your picture, but maybe this is do you want?
our tools :
BuildOwners -> to measure size of the widget before rebuild,
NotificationListeners -> to trigger rebuild based on ScrollNotification. i use stateful Widget, but you can tweak it into ValueNotifier and Build the Sticker with ValueListenableBuilder instead.
ListView.Builder -> actually you can replace this with any kind of Scrollable, we only need to listen scroll event.
how its work?
its simple :
we need to know the P dx Offset, check if C offset small than P, then use that value to adjust x Positioned of C in Stack. and clamp it with max value (P.width)
double _calculateStickerXPosition(
{required double px, required double cx, required double cw}) {
if (cx < px) {
return widget.stickerHorizontalPadding + (px - cx).clamp(0.0, cw - (widget.stickerHorizontalPadding*2));
}
return widget.stickerHorizontalPadding;
}
full code :
main.dart :
import 'dart:ui';
import 'package:flutter/material.dart';
import 'scrollable_sticker.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return MaterialApp(
// i use chrome to test it, so igrone this
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.unknown
},
),
home: const MyWidget(),
);
}
}
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 20.0),
child: ScrollableSticker(
children: List.generate(10, (index) => Container(
width: 500,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
border: Border.all(color: Colors.orange)),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 50.0, horizontal: 50.0),
child: Text(
"P1",
textDirection: TextDirection.ltr,
),
),
)),
stickerBuilder: (index) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: Colors.red),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
'C$index',
),
),
)),
),
);
}
}
scrollable_sticker.dart :
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ScrollableSticker extends StatefulWidget {
final List<Widget> children;
final Widget Function(int index) stickerBuilder;
final double stickerHorizontalPadding;
const ScrollableSticker(
{Key? key,
required this.children,
required this.stickerBuilder,
this.stickerHorizontalPadding = 10.0})
: super(key: key);
#override
State<ScrollableSticker> createState() => _ScrollableStickerState();
}
class _ScrollableStickerState extends State<ScrollableSticker> {
late List<GlobalKey> _keys;
late GlobalKey _parentKey;
#override
void initState() {
super.initState();
_keys = List.generate(widget.children.length, (index) => GlobalKey());
_parentKey = GlobalKey();
}
#override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (sc) {
setState(() {});
return true;
},
child: ListView.builder(
key: _parentKey,
scrollDirection: Axis.horizontal,
itemCount: widget.children.length,
itemBuilder: (context, index) {
final itemSize = measureWidget(Directionality(
textDirection: TextDirection.ltr, child: widget.children[index]));
final stickerSize = measureWidget(Directionality(
textDirection: TextDirection.ltr,
child: widget.stickerBuilder(index)));
final BuildContext? itemContext = _keys[index].currentContext;
double x = widget.stickerHorizontalPadding;
if (itemContext != null) {
final pcontext = _parentKey.currentContext;
Offset? pOffset;
if (pcontext != null) {
RenderObject? obj = pcontext.findRenderObject();
if (obj != null) {
final prb = obj as RenderBox;
pOffset = prb.localToGlobal(Offset.zero);
}
}
final obj = itemContext.findRenderObject();
if (obj != null) {
final rb = obj as RenderBox;
final cx = rb.localToGlobal(pOffset ?? Offset.zero).dx;
x = _calculateStickerXPosition(
px: pOffset != null ? pOffset.dx : 0.0,
cx: cx,
cw: (itemSize.width - stickerSize.width));
}
}
return SizedBox(
key: _keys[index],
height: itemSize.height,
width: itemSize.width,
child: Stack(
children: [
widget.children[index],
Positioned(
top: itemSize.height / 2,
left: x,
child: FractionalTranslation(
translation: const Offset(0.0, -0.5),
child: widget.stickerBuilder(index)))
],
),
);
},
),
);
}
double _calculateStickerXPosition(
{required double px, required double cx, required double cw}) {
if (cx < px) {
return widget.stickerHorizontalPadding +
(px - cx).clamp(0.0, cw - (widget.stickerHorizontalPadding * 2));
}
return widget.stickerHorizontalPadding;
}
}
Size measureWidget(Widget widget) {
final PipelineOwner pipelineOwner = PipelineOwner();
final MeasurementView rootView = pipelineOwner.rootNode = MeasurementView();
final BuildOwner buildOwner = BuildOwner(focusManager: FocusManager());
final RenderObjectToWidgetElement<RenderBox> element =
RenderObjectToWidgetAdapter<RenderBox>(
container: rootView,
debugShortDescription: '[root]',
child: widget,
).attachToRenderTree(buildOwner);
try {
rootView.scheduleInitialLayout();
pipelineOwner.flushLayout();
return rootView.size;
} finally {
// Clean up.
element.update(RenderObjectToWidgetAdapter<RenderBox>(container: rootView));
buildOwner.finalizeTree();
}
}
class MeasurementView extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
#override
void performLayout() {
assert(child != null);
child!.layout(const BoxConstraints(), parentUsesSize: true);
size = child!.size;
}
#override
void debugAssertDoesMeetConstraints() => true;
}
you could try to use c padding dynamically
padding: EdgeInsets.only(left: 0.1 * [index], right: 1 * [index])
for example, I hope it helps.

How to prevent widget from passing out of screen border

i am animating widget by Transform.translate like following
late Offset offsetAll = const Offset(0,0);
Transform.translate(
offset: offsetAll,
child: GestureDetector(
onVerticalDragUpdate: (t){
offsetAll+=t.delta;
setState(() {});
},
child: Container(
height: 100,
padding: const EdgeInsets.all(10),
color: Colors.black54,
),
),
);
i am moving the Container vertically. but the problem is when i move the Container to top or bottom i noticed it could be hidden like following
How could i prevent that ? ..
how can i make it limit .. (if it arrive border so stop move )
i tried to wrap my widget into safeArea but does not work
Edit for Pskink
import 'package:flutter/material.dart';
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
#override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
Offset offset = Offset.zero;
#override
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: (d){
offset = d.localPosition;
setState(() {});
} ,
child: CustomSingleChildLayout(
delegate: FooDelegate(
offset: offset,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(vertical: 20),
),
child: Container(
color: Colors.orange,
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('first line\nsecond line\nthird line'),
),
),
),
),
);
}
}
class FooDelegate extends SingleChildLayoutDelegate {
FooDelegate({
required this.offset,
this.alignment = Alignment.center,
this.padding = EdgeInsets.zero,
}) : super();
final Offset offset;
final Alignment alignment;
final EdgeInsets padding;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.deflate(padding);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
final anchor = alignment.alongSize(childSize);
final effectivePadding = padding + EdgeInsets.fromLTRB(
anchor.dx,
anchor.dy,
childSize.width - anchor.dx,
childSize.height - anchor.dy,
);
final rect = effectivePadding.deflateRect(Offset.zero & size);
return Offset(
offset.dx.clamp(rect.left, rect.right) - anchor.dx,
offset.dy.clamp(rect.top, rect.bottom) - anchor.dy,
);
}
#override
bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => false;
}
You can use CustomSingleChildLayout widget, which lets you position the child of this widget (the Container in your case) while giving you as input the size of the parent.
Why is this relevant? You ask. Well, you need to know the size of the child and the size of the parent in order to keep the child inside the parent bounds.
For example, if you are moving child to the right, then you want to stop moving at the moment you have: topLeftOfChildContainer.dx = Parent.size.width - child.width - paddingRight
If you want to have an idea how you do the calculations, see this method from the custom_positioned_widget class of the controllable_widgets package which uses CustomSingleChildLayout as explained above:
#override
Offset getPositionForChild(Size size, Size childSize) {
// childSize: size of the content
Offset childTopLeft = offsetBuilder.call(childSize);
if (canGoOffParentBounds) {
// no more checks on the position needed
return childTopLeft;
}
// make sure the child does not go off screen in all directions
// and respects the padding
if (childTopLeft.dx + childSize.width > size.width - padding.right) {
final distance =
-(childTopLeft.dx - (size.width - padding.right - childSize.width));
childTopLeft = childTopLeft.translate(distance, 0);
}
if (childTopLeft.dx < padding.left) {
final distance = padding.left - childTopLeft.dx;
childTopLeft = childTopLeft.translate(distance, 0);
}
if (childTopLeft.dy + childSize.height > size.height - padding.bottom) {
final distance = -(childTopLeft.dy -
(size.height - padding.bottom - childSize.height));
childTopLeft = childTopLeft.translate(0, distance);
}
if (childTopLeft.dy < padding.top) {
final distance = padding.top - childTopLeft.dy;
childTopLeft = childTopLeft.translate(0, distance);
}
return childTopLeft;
}
Full Working Example (without any package dependencies):
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 Exp3();
}
}
typedef OffsetBuilder = Offset Function(Size size);
class Exp3 extends StatefulWidget {
const Exp3({Key? key}) : super(key: key);
#override
State<Exp3> createState() => _Exp3State();
}
class _Exp3State extends State<Exp3> {
// function that takes size of the child container and returns its new offset based on the size.
// initial offset of the child container is (0, 0).
OffsetBuilder _offsetBuilder = (_) => Offset.zero;
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Builder(builder: (context) {
return Container( // parent container
color: Colors.red,
child: GestureDetector(
onPanUpdate: (details) {
// get the current offset builder before we modify it
// because we want to use it in the new offset builder
final currentBuilder = _offsetBuilder;
// create the new offset builder
_offsetBuilder = (Size containerSize) {
// the container size will be passed to you in this function
// you can use it to place your widget
// return the offset you like for the top left of the container
// now we will return the current offset + the delta
// Just be careful if you set canGoOffParentBounds to false, as this will prevent the widget from being painted outside the parent
// but it WILL NOT prevent the offset from being updated to be outside parent, you should handle this in this case, see below:
return currentBuilder.call(containerSize) + details.delta;
};
setState(() {}); // to update the UI (force rerender of the CustomSingleChildLayout)
},
child: CustomSingleChildLayout(
delegate: MyCustomSingleChildLayoutDelegate(
canGoOffParentBounds: false,
padding: const EdgeInsets.all(8.0),
offsetBuilder: _offsetBuilder,
),
child: Container(
width: 100,
height: 100,
color: Colors.yellow,
),
),
),
);
}),
);
}
}
class MyCustomSingleChildLayoutDelegate extends SingleChildLayoutDelegate {
final Offset Function(Size childSize) offsetBuilder;
final EdgeInsets padding;
final bool canGoOffParentBounds;
MyCustomSingleChildLayoutDelegate({
required this.offsetBuilder,
required this.padding,
required this.canGoOffParentBounds,
});
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The content can be at most the size of the parent minus 8.0 pixels in each
// direction.
return BoxConstraints.loose(constraints.biggest).deflate(padding);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
// childSize: size of the content
Offset childTopLeft = offsetBuilder.call(childSize);
if (canGoOffParentBounds) {
// no more checks on the position needed
return childTopLeft;
}
// make sure the child does not go off screen in all directions
// and respects the padding
if (childTopLeft.dx + childSize.width > size.width - padding.right) {
final distance = -(childTopLeft.dx - (size.width - padding.right - childSize.width));
childTopLeft = childTopLeft.translate(distance, 0);
}
if (childTopLeft.dx < padding.left) {
final distance = padding.left - childTopLeft.dx;
childTopLeft = childTopLeft.translate(distance, 0);
}
if (childTopLeft.dy + childSize.height > size.height - padding.bottom) {
final distance = -(childTopLeft.dy - (size.height - padding.bottom - childSize.height));
childTopLeft = childTopLeft.translate(0, distance);
}
if (childTopLeft.dy < padding.top) {
final distance = padding.top - childTopLeft.dy;
childTopLeft = childTopLeft.translate(0, distance);
}
return childTopLeft;
}
#override
bool shouldRelayout(MyCustomSingleChildLayoutDelegate oldDelegate) {
return oldDelegate.offsetBuilder != offsetBuilder;
}
}
Note: Please note the comment that tells you that you should not update the offsetBuilder if by updating it, the child becomes outside parent bounds, because although the CustomSingleChildLayout will still paint the child inside the parent, but if you update the offsetBuilder anyway inside your stateful widget's state, you will have inconsistent state between the actual rendered container and the offsetBuilder of your state. So you should also check if child is still inside bounds inside the offsetBuilder.
And if you want you can use CustomPositionedWidget of the mentioned package directly.
p.s.: I am the maintainer of the package above.
here is a simple custom SingleChildLayoutDelegate doing the job (of course it can be simplified a bit if you dont need optional alignment / padding parameters):
class FooDelegate extends SingleChildLayoutDelegate {
FooDelegate({
required this.offset,
this.alignment = Alignment.center,
this.padding = EdgeInsets.zero,
}) : super(relayout: offset);
final ValueNotifier<Offset> offset;
final Alignment alignment;
final EdgeInsets padding;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.deflate(padding);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
final anchor = alignment.alongSize(childSize);
final effectivePadding = padding + EdgeInsets.fromLTRB(
anchor.dx,
anchor.dy,
childSize.width - anchor.dx,
childSize.height - anchor.dy,
);
final rect = effectivePadding.deflateRect(Offset.zero & size);
return Offset(
offset.value.dx.clamp(rect.left, rect.right) - anchor.dx,
offset.value.dy.clamp(rect.top, rect.bottom) - anchor.dy,
);
}
#override
bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => false;
}
test widget:
class Foo extends StatelessWidget {
final offset = ValueNotifier(Offset.zero);
#override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (d) => offset.value = d.localPosition,
onPanUpdate: (d) => offset.value = d.localPosition,
child: CustomSingleChildLayout(
delegate: FooDelegate(
offset: offset,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(vertical: 20),
),
child: Container(
color: Colors.orange,
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('first line\nsecond line\nthird line'),
),
),
),
);
}
}
EDIT
a less efficient version using setState instead of ValueNotifier:
class Foo extends StatefulWidget {
#override
State<Foo> createState() => _FooState();
}
class _FooState extends State<Foo> {
var offset = Offset.zero;
#override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (d) => setState(() => offset = d.localPosition),
onPanUpdate: (d) => setState(() => offset = d.localPosition),
child: CustomSingleChildLayout(
delegate: FooDelegate(
offset: offset,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(vertical: 20),
),
child: Container(
color: Colors.orange,
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('first line\nsecond line\nthird line'),
),
),
),
);
}
}
class FooDelegate extends SingleChildLayoutDelegate {
FooDelegate({
required this.offset,
this.alignment = Alignment.center,
this.padding = EdgeInsets.zero,
});
final Offset offset;
final Alignment alignment;
final EdgeInsets padding;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.deflate(padding);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
final anchor = alignment.alongSize(childSize);
final effectivePadding = padding + EdgeInsets.fromLTRB(
anchor.dx,
anchor.dy,
childSize.width - anchor.dx,
childSize.height - anchor.dy,
);
final rect = effectivePadding.deflateRect(Offset.zero & size);
return Offset(
offset.dx.clamp(rect.left, rect.right) - anchor.dx,
offset.dy.clamp(rect.top, rect.bottom) - anchor.dy,
);
}
#override
bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => true;
}

Drawer causing first DropdownButton child to inhert top padding/margin in its 'dropwdown button list'?

Depending on how my drawer is built, some child components receive top padding, messing up childs behavior, including this DropdownButton 'dropwdown button list' to misalign.
Drawer(
child: Column(
children: <Widget>[
Container(color: Colors.amber,
child: DropdownButtonHideUnderline(
child: ButtonTheme(
alignedDropdown: true,
child: DropdownButton<String>(
value: 'Company 1',
onChanged: (String newValue) {
// TODO change company
},
items: <String>['Company 1', 'Company 2', 'Company 3', 'Company 4'].map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
),
),
),
),
Expanded(
child: ListView(
children: <Widget>[
ListTile(
leading: Icon(Icons.home),
title: Text(AppLocalizations.of(context).homeDrawerHomeTitle),
onTap: () async => GetIt.I.get<NavigationService>().pushReplacementNamed(AppRouter.root),
),
ListTile(
leading: Icon(Icons.logout),
title: Text(AppLocalizations.of(context).homeDrawerLogoutTitle),
onTap: () async => GetIt.I.get<AuthenticationService>().signOutUser(),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
children: <Widget>[
Divider(),
StreamBuilder<User>(
stream: GetIt.I.get<AuthenticationService>().getCurrentUserStream(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListTile(
leading: Icon(Icons.login),
title: Text(AppLocalizations.of(context).homeDrawerProfileTitle),
onTap: () {
GetIt.I.get<NavigationService>().navigateTo(AppRouter.profile);
},
);
}
return ListTile(
leading: Icon(Icons.login),
title: Text(AppLocalizations.of(context).homeDrawerLoginTitle),
onTap: () {
GetIt.I.get<NavigationService>().navigateTo(AppRouter.login);
},
);
},
),
ListTile(
leading: Icon(Icons.settings),
title: Text(AppLocalizations.of(context).homeDrawerSettingsTitle),
onTap: () {
_scaffoldKey.currentState.removeCurrentSnackBar();
// TODO change route to settings
GetIt.I.get<NavigationService>().navigateTo(AppRouter.root);
},
),
SizedBox(
height: 5.0,
)
],
),
)
],
),
),
Image pointing the 'forehead' that magically appears in the 'dropwdown button list' that should not be there:
Image pointing the padding required or the 'dropwdown button list' does not open correctly over the DropDownButton:
My main questions are:
Why the Container around the DropdownButton widgets needs a '56' top
padding or the 'dropwdown button list' misaligns when clicked/opened?
What is 'pushing down' the 'dropwdown button list' out of alignment?
Why the list is also getting more top padding out of nowhere?
Create Custom Class For DropdownButton and write this.
import 'dart:math' as math;
import 'package:flutter/material.dart';
const Duration _kDropdownMenuDuration = Duration(milliseconds: 300);
const double _kMenuItemHeight = 48.0;
const double _kDenseButtonHeight = 24.0;
const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
const EdgeInsetsGeometry _kAlignedButtonPadding =
EdgeInsetsDirectional.only(start: 16.0, end: 4.0);
const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero;
const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero;
const EdgeInsetsGeometry _kUnalignedMenuMargin =
EdgeInsetsDirectional.only(start: 16.0, end: 24.0);
class _DropdownMenuPainter extends CustomPainter {
_DropdownMenuPainter({
this.color,
this.elevation,
this.selectedIndex,
this.resize,
}) : _painter = new BoxDecoration(
// If you add an image here, you must provide a real
// configuration in the paint() function and you must provide some sort
// of onChanged callback here.
color: color,
borderRadius: new BorderRadius.circular(2.0),
boxShadow: kElevationToShadow[elevation])
.createBoxPainter(),
super(repaint: resize);
final Color color;
final int elevation;
final int selectedIndex;
final Animation<double> resize;
final BoxPainter _painter;
#override
void paint(Canvas canvas, Size size) {
final double selectedItemOffset =
selectedIndex * _kMenuItemHeight + kMaterialListPadding.top;
final Tween<double> top = new Tween<double>(
begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight),
end: 0.0,
);
final Tween<double> bottom = new Tween<double>(
begin:
(top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height),
end: size.height,
);
final Rect rect = new Rect.fromLTRB(
0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));
_painter.paint(
canvas, rect.topLeft, new ImageConfiguration(size: rect.size));
}
#override
bool shouldRepaint(_DropdownMenuPainter oldPainter) {
return oldPainter.color != color ||
oldPainter.elevation != elevation ||
oldPainter.selectedIndex != selectedIndex ||
oldPainter.resize != resize;
}
}
// Do not use the platform-specific default scroll configuration.
// Dropdown menus should never overscroll or display an overscroll indicator.
class _DropdownScrollBehavior extends ScrollBehavior {
const _DropdownScrollBehavior();
#override
TargetPlatform getPlatform(BuildContext context) =>
Theme.of(context).platform;
#override
Widget buildViewportChrome(
BuildContext context, Widget child, AxisDirection axisDirection) =>
child;
#override
ScrollPhysics getScrollPhysics(BuildContext context) =>
const ClampingScrollPhysics();
}
class _DropdownMenu<T> extends StatefulWidget {
const _DropdownMenu({
Key key,
this.padding,
this.route,
}) : super(key: key);
final _DropdownRoute<T> route;
final EdgeInsets padding;
#override
_DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
}
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
CurvedAnimation _fadeOpacity;
CurvedAnimation _resize;
#override
void initState() {
super.initState();
// We need to hold these animations as state because of their curve
// direction. When the route's animation reverses, if we were to recreate
// the CurvedAnimation objects in build, we'd lose
// CurvedAnimation._curveDirection.
_fadeOpacity = new CurvedAnimation(
parent: widget.route.animation,
curve: const Interval(0.0, 0.25),
reverseCurve: const Interval(0.75, 1.0),
);
_resize = new CurvedAnimation(
parent: widget.route.animation,
curve: const Interval(0.25, 0.5),
reverseCurve: const Threshold(0.0),
);
}
#override
Widget build(BuildContext context) {
// The menu is shown in three stages (unit timing in brackets):
// [0s - 0.25s] - Fade in a rect-sized menu container with the selected item.
// [0.25s - 0.5s] - Grow the otherwise empty menu container from the center
// until it's big enough for as many items as we're going to show.
// [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
//
// When the menu is dismissed we just fade the entire thing out
// in the first 0.25s.
final MaterialLocalizations localizations =
MaterialLocalizations.of(context);
final _DropdownRoute<T> route = widget.route;
final double unit = 0.5 / (route.items.length + 1.5);
final List<Widget> children = <Widget>[];
for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) {
CurvedAnimation opacity;
if (itemIndex == route.selectedIndex) {
opacity = new CurvedAnimation(
parent: route.animation, curve: const Threshold(0.0));
} else {
final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0);
final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
opacity = new CurvedAnimation(
parent: route.animation, curve: new Interval(start, end));
}
children.add(new FadeTransition(
opacity: opacity,
child: new InkWell(
child: new Container(
padding: widget.padding,
child: route.items[itemIndex],
),
onTap: () => Navigator.pop(
context,
new _DropdownRouteResult<T>(route.items[itemIndex].value),
),
),
));
}
return new FadeTransition(
opacity: _fadeOpacity,
child: new CustomPaint(
painter: new _DropdownMenuPainter(
color: Theme.of(context).canvasColor,
elevation: route.elevation,
selectedIndex: route.selectedIndex,
resize: _resize,
),
child: new Semantics(
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
label: localizations.popupMenuLabel,
child: new Material(
type: MaterialType.transparency,
textStyle: route.style,
child: new ScrollConfiguration(
behavior: const _DropdownScrollBehavior(),
child: new Scrollbar(
child: new ListView(
controller: widget.route.scrollController,
padding: kMaterialListPadding,
itemExtent: _kMenuItemHeight,
shrinkWrap: true,
children: children,
),
),
),
),
),
),
);
}
}
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
_DropdownMenuRouteLayout({
#required this.buttonRect,
#required this.menuTop,
#required this.menuHeight,
#required this.textDirection,
});
final Rect buttonRect;
final double menuTop;
final double menuHeight;
final TextDirection textDirection;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The maximum height of a simple menu should be one or more rows less than
// the view height. This ensures a tappable area outside of the simple menu
// with which to dismiss the menu.
// -- https://material.google.com/components/menus.html#menus-simple-menus
final double maxHeight =
math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
// The width of a menu should be at most the view width. This ensures that
// the menu does not extend past the left and right edges of the screen.
final double width = math.min(constraints.maxWidth, buttonRect.width);
return new BoxConstraints(
minWidth: width,
maxWidth: width,
minHeight: 0.0,
maxHeight: maxHeight,
);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
assert(() {
final Rect container = Offset.zero & size; // this gives the zero offset
if (container.intersect(buttonRect) == buttonRect) {
// If the button was entirely on-screen, then verify
// that the menu is also on-screen.
// If the button was a bit off-screen, then, oh well.
assert(menuTop >= 0.0);
assert(menuTop + menuHeight <= size.height);
}
return true;
}());
assert(textDirection != null);
double left;
switch (textDirection) {
case TextDirection.rtl:
left = buttonRect.right.clamp(0.0, size.width) - childSize.width;
break;
case TextDirection.ltr:
left = buttonRect.left.clamp(0.0, size.width - childSize.width);
break;
}
return new Offset(left, menuTop);
}
#override
bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) {
return buttonRect != oldDelegate.buttonRect ||
menuTop != oldDelegate.menuTop ||
menuHeight != oldDelegate.menuHeight ||
textDirection != oldDelegate.textDirection;
}
}
class _DropdownRouteResult<T> {
const _DropdownRouteResult(this.result);
final T result;
#override
bool operator ==(dynamic other) {
if (other is! _DropdownRouteResult<T>) return false;
final _DropdownRouteResult<T> typedOther = other;
return result == typedOther.result;
}
#override
int get hashCode => result.hashCode;
}
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
_DropdownRoute({
this.items,
this.padding,
this.buttonRect,
this.selectedIndex,
this.elevation = 8,
this.theme,
#required this.style,
this.barrierLabel,
}) : assert(style != null);
final List<DropdownMenuItem<T>> items;
final EdgeInsetsGeometry padding;
final Rect buttonRect;
final int selectedIndex;
final int elevation;
final ThemeData theme;
final TextStyle style;
ScrollController scrollController;
#override
Duration get transitionDuration => _kDropdownMenuDuration;
#override
bool get barrierDismissible => true;
#override
Color get barrierColor => null;
#override
final String barrierLabel;
#override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
assert(debugCheckHasDirectionality(context));
final double screenHeight = MediaQuery.of(context).size.height;
final double maxMenuHeight = screenHeight - 2.0 * _kMenuItemHeight;
final double preferredMenuHeight =
(items.length * _kMenuItemHeight) + kMaterialListPadding.vertical;
final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight);
final double buttonTop = buttonRect.top;
final double selectedItemOffset =
selectedIndex * _kMenuItemHeight + kMaterialListPadding.top;
double menuTop = (buttonTop - selectedItemOffset) -
(_kMenuItemHeight - buttonRect.height) / 2.0;
const double topPreferredLimit = _kMenuItemHeight;
if (menuTop < topPreferredLimit)
menuTop = math.min(buttonTop, topPreferredLimit);
double bottom = menuTop + menuHeight;
final double bottomPreferredLimit = screenHeight - _kMenuItemHeight;
if (bottom > bottomPreferredLimit) {
bottom = math.max(buttonTop + _kMenuItemHeight, bottomPreferredLimit);
menuTop = bottom - menuHeight;
}
if (scrollController == null) {
double scrollOffset = 0.0;
if (preferredMenuHeight > maxMenuHeight)
scrollOffset = selectedItemOffset - (buttonTop - menuTop);
scrollController =
new ScrollController(initialScrollOffset: scrollOffset);
}
final TextDirection textDirection = Directionality.of(context);
Widget menu = new _DropdownMenu<T>(
route: this,
padding: padding.resolve(textDirection),
);
if (theme != null) menu = new Theme(data: theme, child: menu);
return new MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: new Builder(
builder: (BuildContext context) {
return new CustomSingleChildLayout(
delegate: new _DropdownMenuRouteLayout<T>(
buttonRect: buttonRect,
menuTop: menuTop,
menuHeight: menuHeight,
textDirection: textDirection,
),
child: menu,
);
},
),
);
}
void _dismiss() {
navigator?.removeRoute(this);
}
}
class CustomDropdownButton<T> extends StatefulWidget {
/// Creates a dropdown button.
///
/// The [items] must have distinct values and if [value] isn't null it must be among them.
///
/// The [elevation] and [iconSize] arguments must not be null (they both have
/// defaults, so do not need to be specified).
CustomDropdownButton({
Key key,
#required this.items,
this.value,
this.hint,
#required this.onChanged,
this.elevation = 8,
this.style,
this.iconSize = 24.0,
this.isDense = false,
}) : assert(items != null),
assert(value == null ||
items
.where((DropdownMenuItem<T> item) => item.value == value)
.length ==
1),
super(key: key);
/// The list of possible items to select among.
final List<DropdownMenuItem<T>> items;
/// The currently selected item, or null if no item has been selected. If
/// value is null then the menu is popped up as if the first item was
/// selected.
final T value;
/// Displayed if [value] is null.
final Widget hint;
/// Called when the user selects an item.
final ValueChanged<T> onChanged;
/// The z-coordinate at which to place the menu when open.
///
/// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24
///
/// Defaults to 8, the appropriate elevation for dropdown buttons.
final int elevation;
/// The text style to use for text in the dropdown button and the dropdown
/// menu that appears when you tap the button.
///
/// Defaults to the [TextTheme.subhead] value of the current
/// [ThemeData.textTheme] of the current [Theme].
final TextStyle style;
/// The size to use for the drop-down button's down arrow icon button.
///
/// Defaults to 24.0.
final double iconSize;
/// Reduce the button's height.
///
/// By default this button's height is the same as its menu items' heights.
/// If isDense is true, the button's height is reduced by about half. This
/// can be useful when the button is embedded in a container that adds
/// its own decorations, like [InputDecorator].
final bool isDense;
#override
_DropdownButtonState<T> createState() => new _DropdownButtonState<T>();
}
class _DropdownButtonState<T> extends State<CustomDropdownButton<T>>
with WidgetsBindingObserver {
int _selectedIndex;
_DropdownRoute<T> _dropdownRoute;
#override
void initState() {
super.initState();
// _updateSelectedIndex();
WidgetsBinding.instance.addObserver(this);
}
#override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_removeDropdownRoute();
super.dispose();
}
// Typically called because the device's orientation has changed.
// Defined by WidgetsBindingObserver
#override
void didChangeMetrics() {
_removeDropdownRoute();
}
void _removeDropdownRoute() {
_dropdownRoute?._dismiss();
_dropdownRoute = null;
}
#override
void didUpdateWidget(CustomDropdownButton<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_updateSelectedIndex();
}
void _updateSelectedIndex() {
assert(widget.value == null ||
widget.items
.where((DropdownMenuItem<T> item) => item.value == widget.value)
.length ==
1);
_selectedIndex = null;
for (int itemIndex = 0; itemIndex < widget.items.length; itemIndex++) {
if (widget.items[itemIndex].value == widget.value) {
_selectedIndex = itemIndex;
return;
}
}
}
TextStyle get _textStyle =>
widget.style ?? Theme.of(context).textTheme.subhead;
void _handleTap() {
final RenderBox itemBox = context.findRenderObject();
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
final TextDirection textDirection = Directionality.of(context);
final EdgeInsetsGeometry menuMargin =
ButtonTheme.of(context).alignedDropdown
? _kAlignedMenuMargin
: _kUnalignedMenuMargin;
assert(_dropdownRoute == null);
_dropdownRoute = new _DropdownRoute<T>(
items: widget.items,
buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
padding: _kMenuItemPadding.resolve(textDirection),
selectedIndex: -1,
elevation: widget.elevation,
theme: Theme.of(context, shadowThemeOnly: true),
style: _textStyle,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
);
Navigator.push(context, _dropdownRoute)
.then<void>((_DropdownRouteResult<T> newValue) {
_dropdownRoute = null;
if (!mounted || newValue == null) return;
if (widget.onChanged != null) widget.onChanged(newValue.result);
});
}
// When isDense is true, reduce the height of this button from _kMenuItemHeight to
// _kDenseButtonHeight, but don't make it smaller than the text that it contains.
// Similarly, we don't reduce the height of the button so much that its icon
// would be clipped.
double get _denseButtonHeight {
return math.max(
_textStyle.fontSize, math.max(widget.iconSize, _kDenseButtonHeight));
}
#override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
// The width of the button and the menu are defined by the widest
// item and the width of the hint.
final List<Widget> items = new List<Widget>.from(widget.items);
int hintIndex;
if (widget.hint != null) {
hintIndex = items.length;
items.add(new DefaultTextStyle(
style: _textStyle.copyWith(color: Theme.of(context).hintColor),
child: new IgnorePointer(
child: widget.hint,
ignoringSemantics: false,
),
));
}
final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
? _kAlignedButtonPadding
: _kUnalignedButtonPadding;
Widget result = new DefaultTextStyle(
style: _textStyle,
child: new Container(
padding: padding.resolve(Directionality.of(context)),
height: widget.isDense ? _denseButtonHeight : null,
child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// If value is null (then _selectedIndex is null) then we display
// the hint or nothing at all.
Expanded(
child: new IndexedStack(
index: _selectedIndex ?? hintIndex,
alignment: AlignmentDirectional.centerStart,
children: items,
),
),
new Icon(Icons.arrow_drop_down,
size: widget.iconSize,
// These colors are not defined in the Material Design spec.
color: Theme.of(context).brightness == Brightness.light
? Colors.grey.shade700
: Colors.white70),
],
),
),
);
if (!DropdownButtonHideUnderline.at(context)) {
final double bottom = widget.isDense ? 0.0 : 8.0;
result = new Stack(
children: <Widget>[
result,
new Positioned(
left: 0.0,
right: 0.0,
bottom: bottom,
child: new Container(
height: 1.0,
decoration: const BoxDecoration(
border: Border(
bottom:
BorderSide(color: Color(0xFFBDBDBD), width: 0.0))),
),
),
],
);
}
return new Semantics(
button: true,
child: new GestureDetector(
onTap: _handleTap, behavior: HitTestBehavior.opaque, child: result),
);
}
}
this gives the zero offset of your drop-down button.
you can use like this
CustomDropdownButton(
value: Company,
items: dropdownItems,
onChanged: onChange,
),
you can customize it as you want like padding and margin and icon as well

Flutter DropDown button for account settings

Want to design a custom drop down button for account settings with logout option.
How to change drop down list background color?
How to customize drop down list?
DropdownButton(
hint: ButtonText(
buttonText: "Select a option",
textColor: ThemeColors.grey8,
buttonFontSize: 20.0,
),
value: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
},
items: _options.map((option) {
return DropdownMenuItem(
child: ButtonText(
buttonText: option,
buttonFontSize: 16.0,
textColor: ThemeColors.grey8,
),
value: option,
);
}).toList(),
i want design a custom drop down button with a account dashboard.
I had a similar situation. I needed to implement a dropdown dialog. I used the DropdownButton sources and modified them.
My widget:
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
const double _hintWidgetHeight = 34;
const double _hintWidgetWidth = 140;
class MyHintWidget extends StatefulWidget {
const MyHintWidget({
Key key,
#required this.title,
#required this.style,
#required this.child,
}) : super(key: key);
final String title;
final TextStyle style;
final Widget child;
#override
_MyHintState createState() => _MyHintState();
}
class _MyHintState extends State<MyHintWidget> with WidgetsBindingObserver {
static const EdgeInsets _hintItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
_HintRoute route;
#override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
#override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_removeRoute();
super.dispose();
}
#override
void didChangeMetrics() {
_removeRoute();
}
void _removeRoute() {
route?._dismiss();
route = null;
}
void _handleTap() {
final RenderBox itemBox = context.findRenderObject() as RenderBox;
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
final TextDirection textDirection = Directionality.of(context);
route = _HintRoute(
title: widget.title,
style: widget.style,
buttonRect: _hintItemPadding.resolve(textDirection).inflateRect(itemRect),
padding: _hintItemPadding.resolve(textDirection),
theme: ThemeData(
brightness: Brightness.light,
),
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
);
Navigator.push(context, route).then<void>((_) {
route = null;
});
}
#override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: GestureDetector(
onTap: _handleTap,
behavior: HitTestBehavior.opaque,
child: widget.child,
),
);
}
}
class _HintRoute extends PopupRoute<void> {
_HintRoute({
#required this.title,
#required this.style,
this.padding,
this.buttonRect,
this.theme,
this.barrierLabel,
});
final String title;
final TextStyle style;
final EdgeInsetsGeometry padding;
final Rect buttonRect;
final ThemeData theme;
#override
Duration get transitionDuration => const Duration(milliseconds: 300);
#override
bool get barrierDismissible => true;
#override
Color get barrierColor => null;
#override
final String barrierLabel;
#override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
return _HintRoutePage(
title: title,
style: style,
route: this,
constraints: constraints,
padding: padding,
buttonRect: buttonRect,
theme: theme,
);
});
}
void _dismiss() => navigator?.removeRoute(this);
}
class _HintRoutePage extends StatelessWidget {
const _HintRoutePage({
Key key,
#required this.title,
#required this.style,
this.route,
this.constraints,
this.padding,
this.buttonRect,
this.theme,
}) : super(key: key);
final String title;
final TextStyle style;
final _HintRoute route;
final BoxConstraints constraints;
final EdgeInsetsGeometry padding;
final Rect buttonRect;
final ThemeData theme;
#override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final double availableHeight = constraints.maxHeight;
final double maxHintHeight = availableHeight - 2.0 * _hintWidgetHeight;
final double buttonTop = buttonRect.top;
final double buttonBottom = math.min(buttonRect.bottom, availableHeight);
final double topLimit = math.min(_hintWidgetHeight, buttonTop);
final double bottomLimit = math.max(availableHeight - _hintWidgetHeight, buttonBottom);
final double selectedItemOffset = _hintWidgetHeight + kMaterialListPadding.top;
double hintTop =
(buttonTop - selectedItemOffset) - (_hintWidgetHeight * 2 - buttonRect.height) / 3;
final double preferredHintHeight = _hintWidgetHeight + kMaterialListPadding.vertical;
final double hintHeight = math.min(maxHintHeight, preferredHintHeight);
double hintBottom = hintTop + hintHeight;
if (hintTop < topLimit) hintTop = math.min(buttonTop, topLimit);
if (hintBottom > bottomLimit) {
hintBottom = math.max(buttonBottom, bottomLimit);
hintTop = hintBottom - hintHeight;
}
final TextDirection textDirection = Directionality.of(context);
Widget hint = _HintWidget(
title: title,
style: style,
route: route,
padding: padding.resolve(textDirection),
);
if (theme != null) hint = Theme(data: theme, child: hint);
return MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: Builder(
builder: (BuildContext context) {
return CustomSingleChildLayout(
delegate: _HintRouteLayout(
buttonRect: buttonRect,
hintTop: hintTop,
hintHeight: hintHeight,
textDirection: textDirection,
),
child: hint,
);
},
),
);
}
}
class _HintRouteLayout extends SingleChildLayoutDelegate {
_HintRouteLayout({
#required this.buttonRect,
#required this.hintTop,
#required this.hintHeight,
#required this.textDirection,
});
final Rect buttonRect;
final double hintTop;
final double hintHeight;
final TextDirection textDirection;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _hintWidgetHeight);
const double width = _hintWidgetWidth;
return BoxConstraints(
minWidth: width,
maxWidth: width,
minHeight: 0.0,
maxHeight: maxHeight,
);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
assert(() {
final Rect container = Offset.zero & size;
if (container.intersect(buttonRect) == buttonRect) {
assert(hintTop >= 0.0);
assert(hintTop + hintHeight <= size.height);
}
return true;
}());
assert(textDirection != null);
double left;
if (buttonRect.left > size.width - childSize.width / 1.1) {
left = size.width - childSize.width;
} else if (buttonRect.left < childSize.width / 5) {
left = 0;
} else {
left = buttonRect.left - (buttonRect.right - buttonRect.left) / 3;
}
return Offset(left, hintTop);
}
#override
bool shouldRelayout(_HintRouteLayout oldDelegate) {
return buttonRect != oldDelegate.buttonRect ||
hintTop != oldDelegate.hintTop ||
hintHeight != oldDelegate.hintHeight ||
textDirection != oldDelegate.textDirection;
}
}
class _HintWidget extends StatefulWidget {
const _HintWidget({
Key key,
#required this.title,
#required this.style,
this.padding,
this.route,
}) : super(key: key);
final String title;
final TextStyle style;
final _HintRoute route;
final EdgeInsets padding;
#override
_HintState createState() => _HintState();
}
class _HintState extends State<_HintWidget> {
CurvedAnimation _fadeOpacity;
CurvedAnimation _resize;
#override
void initState() {
super.initState();
_fadeOpacity = CurvedAnimation(
parent: widget.route.animation,
curve: const Interval(0.0, 0.25),
reverseCurve: const Interval(0.75, 1.0),
);
_resize = CurvedAnimation(
parent: widget.route.animation,
curve: const Interval(0.25, 0.5),
reverseCurve: const Threshold(0.0),
);
}
#override
Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final _HintRoute route = widget.route;
const double unit = 0.5 / 1.5;
CurvedAnimation opacity;
final double start = (0.5 + 1 * unit).clamp(0.0, 1.0) as double;
final double end = (start + 1.5 * unit).clamp(0.0, 1.0) as double;
opacity = CurvedAnimation(parent: route.animation, curve: Interval(start, end));
return FadeTransition(
opacity: _fadeOpacity,
child: CustomPaint(
painter: _HintPainter(
color: Theme.of(context).canvasColor,
elevation: 8,
resize: _resize,
),
child: Semantics(
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
label: localizations.popupMenuLabel,
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: kMaterialListPadding,
child: FadeTransition(
opacity: opacity,
child: InkWell(
onTap: () => Navigator.pop(context),
child: Container(
padding: widget.padding,
child: Text(
widget.title,
style: widget.style,
textAlign: TextAlign.center,
),
),
),
),
),
),
),
),
);
}
}
class _HintPainter extends CustomPainter {
_HintPainter({
this.color,
this.elevation,
this.resize,
}) : _painter = BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6.0),
boxShadow: kElevationToShadow[elevation],
).createBoxPainter(),
super(repaint: resize);
final Color color;
final int elevation;
final Animation<double> resize;
final BoxPainter _painter;
#override
void paint(Canvas canvas, Size size) {
final double selectedItemOffset = _hintWidgetHeight + kMaterialListPadding.top;
final Tween<double> top = Tween<double>(
begin: selectedItemOffset.clamp(0.0, size.height - _hintWidgetHeight) as double,
end: 0.0,
);
final Tween<double> bottom = Tween<double>(
begin: (top.begin + _hintWidgetHeight).clamp(_hintWidgetHeight, size.height) as double,
end: size.height,
);
final Rect rect = Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));
_painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
}
#override
bool shouldRepaint(_HintPainter oldPainter) {
return oldPainter.color != color ||
oldPainter.elevation != elevation ||
oldPainter.resize != resize;
}
}
So it is used:
return MyHintWidget(
title: 'some text',
style: //text style[![enter image description here][1]][1],
child: //child,
);
Result

How to create page curl effect

I want to apply a page curl transformation to PageView's pages similar to the attached screenshots.
Here you can see a video of the effect: https://www.youtube.com/watch?v=JqvtZwIJMLo
Not sure if this is possible to reproduce in Flutter by just applying a transformation matrix because as you can see it's using a A*sin(2*π/wav*x) equation.
You can find the most recent version here: https://gist.github.com/slightfoot/1ac2e44f68c9edd9c830f8d935b5866d
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// 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.
//
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.indigo,
accentColor: Colors.pinkAccent,
),
home: ExampleScreen(),
),
);
}
class ExampleScreen extends StatefulWidget {
#override
_ExampleScreenState createState() => _ExampleScreenState();
}
class _ExampleScreenState extends State<ExampleScreen> with SingleTickerProviderStateMixin {
AnimationController _controller;
#override
void initState() {
super.initState();
_controller = AnimationController(
value: 0.5,
duration: const Duration(milliseconds: 450),
vsync: this,
);
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTap() {
if (_controller.status == AnimationStatus.dismissed || _controller.status == AnimationStatus.reverse) {
_controller.forward();
} else {
_controller.reverse();
}
}
#override
Widget build(BuildContext context) {
return Material(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTap,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
PageTurnImage(
amount: AlwaysStoppedAnimation(1.0),
image: NetworkImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/John_Masefield.djvu/page10-1024px-John_Masefield.djvu.jpg'),
),
PageTurnWidget(
amount: _controller,
child: AlicePage1(),
),
Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
height: 48.0,
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget child) {
return Slider(
value: _controller.value,
onChanged: (double value) {
_controller.value = value;
},
);
},
),
),
],
),
),
);
}
}
class AlicePage1 extends StatelessWidget {
#override
Widget build(BuildContext context) {
return DefaultTextStyle.merge(
style: TextStyle(fontSize: 16.0),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
"CHAPTER I",
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
Text(
"Down the Rabbit-Hole",
style: TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32.0),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Text("Alice was beginning to get very tired of sitting by her sister on the bank, and of"
" having nothing to do: once or twice she had peeped into the book her sister was "
"reading, but it had no pictures or conversations in it, `and what is the use of "
"a book,' thought Alice `without pictures or conversation?'"),
),
Container(
margin: const EdgeInsets.only(left: 12.0),
color: Colors.black26,
width: 160.0,
height: 220.0,
child: Placeholder(),
),
],
),
const SizedBox(height: 16.0),
Text(
"So she was considering in her own mind (as well as she could, for the hot day made her "
"feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be "
"worth the trouble of getting up and picking the daisies, when suddenly a White "
"Rabbit with pink eyes ran close by her.\n"
"\n"
"There was nothing so very remarkable in that; nor did Alice think it so very much out "
"of the way to hear the Rabbit say to itself, `Oh dear! Oh dear! I shall be "
"late!' (when she thought it over afterwards, it occurred to her that she ought to "
"have wondered at this, but at the time it all seemed quite natural); but when the "
"Rabbit actually took a watch out of its waistcoat-pocket, and looked at it, and then "
"hurried on, Alice started to her feet, for it flashed across her mind that she had "
"never before seen a rabbit with either a waistcoat-pocket, or a watch to take out "
"of it, and burning with curiosity, she ran across the field after it, and fortunately "
"was just in time to see it pop down a large rabbit-hole under the hedge.",
),
],
),
),
),
);
}
}
// -----------------
class PageTurnWidget extends StatefulWidget {
const PageTurnWidget({
Key key,
this.amount,
this.backgroundColor = const Color(0xFFFFFFCC),
this.child,
}) : super(key: key);
final Animation<double> amount;
final Color backgroundColor;
final Widget child;
#override
_PageTurnWidgetState createState() => _PageTurnWidgetState();
}
class _PageTurnWidgetState extends State<PageTurnWidget> {
final _boundaryKey = GlobalKey();
ui.Image _image;
#override
void didUpdateWidget(PageTurnWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.child != widget.child) {
_image = null;
}
}
void _captureImage(Duration timeStamp) async {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final boundary = _boundaryKey.currentContext.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: pixelRatio);
setState(() => _image = image);
}
#override
Widget build(BuildContext context) {
if (_image != null) {
return CustomPaint(
painter: _PageTurnEffect(
amount: widget.amount,
image: _image,
backgroundColor: widget.backgroundColor,
),
size: Size.infinite,
);
} else {
WidgetsBinding.instance.addPostFrameCallback(_captureImage);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final size = constraints.biggest;
return Stack(
overflow: Overflow.clip,
children: <Widget>[
Positioned(
left: 1 + size.width,
top: 1 + size.height,
width: size.width,
height: size.height,
child: RepaintBoundary(
key: _boundaryKey,
child: widget.child,
),
),
],
);
},
);
}
}
}
class PageTurnImage extends StatefulWidget {
const PageTurnImage({
Key key,
this.amount,
this.image,
this.backgroundColor = const Color(0xFFFFFFCC),
}) : super(key: key);
final Animation<double> amount;
final ImageProvider image;
final Color backgroundColor;
#override
_PageTurnImageState createState() => _PageTurnImageState();
}
class _PageTurnImageState extends State<PageTurnImage> {
ImageStream _imageStream;
ImageInfo _imageInfo;
bool _isListeningToStream = false;
ImageStreamListener _imageListener;
#override
void initState() {
super.initState();
_imageListener = ImageStreamListener(_handleImageFrame);
}
#override
void dispose() {
_stopListeningToStream();
super.dispose();
}
#override
void didChangeDependencies() {
_resolveImage();
if (TickerMode.of(context)) {
_listenToStream();
} else {
_stopListeningToStream();
}
super.didChangeDependencies();
}
#override
void didUpdateWidget(PageTurnImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.image != oldWidget.image) {
_resolveImage();
}
}
#override
void reassemble() {
_resolveImage(); // in case the image cache was flushed
super.reassemble();
}
void _resolveImage() {
final ImageStream newStream = widget.image.resolve(createLocalImageConfiguration(context));
assert(newStream != null);
_updateSourceStream(newStream);
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() => _imageInfo = imageInfo);
}
// Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was
// registered).
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream?.key) return;
if (_isListeningToStream) _imageStream.removeListener(_imageListener);
_imageStream = newStream;
if (_isListeningToStream) _imageStream.addListener(_imageListener);
}
void _listenToStream() {
if (_isListeningToStream) return;
_imageStream.addListener(_imageListener);
_isListeningToStream = true;
}
void _stopListeningToStream() {
if (!_isListeningToStream) return;
_imageStream.removeListener(_imageListener);
_isListeningToStream = false;
}
#override
Widget build(BuildContext context) {
if (_imageInfo != null) {
return CustomPaint(
painter: _PageTurnEffect(
amount: widget.amount,
image: _imageInfo.image,
backgroundColor: widget.backgroundColor,
),
size: Size.infinite,
);
} else {
return const SizedBox();
}
}
}
class _PageTurnEffect extends CustomPainter {
_PageTurnEffect({
#required this.amount,
#required this.image,
this.backgroundColor,
this.radius = 0.18,
}) : assert(amount != null && image != null && radius != null),
super(repaint: amount);
final Animation<double> amount;
final ui.Image image;
final Color backgroundColor;
final double radius;
#override
void paint(ui.Canvas canvas, ui.Size size) {
final pos = amount.value;
final movX = (1.0 - pos) * 0.85;
final calcR = (movX < 0.20) ? radius * movX * 5 : radius;
final wHRatio = 1 - calcR;
final hWRatio = image.height / image.width;
final hWCorrection = (hWRatio - 1.0) / 2.0;
final w = size.width.toDouble();
final h = size.height.toDouble();
final c = canvas;
final shadowXf = (wHRatio - movX);
final shadowSigma = Shadow.convertRadiusToSigma(8.0 + (32.0 * (1.0 - shadowXf)));
final pageRect = Rect.fromLTRB(0.0, 0.0, w * shadowXf, h);
if (backgroundColor != null) {
c.drawRect(pageRect, Paint()..color = backgroundColor);
}
c.drawRect(
pageRect,
Paint()
..color = Colors.black54
..maskFilter = MaskFilter.blur(BlurStyle.outer, shadowSigma),
);
final ip = Paint();
for (double x = 0; x < size.width; x++) {
final xf = (x / w);
final v = (calcR * (math.sin(math.pi / 0.5 * (xf - (1.0 - pos)))) + (calcR * 1.1));
final xv = (xf * wHRatio) - movX;
final sx = (xf * image.width);
final sr = Rect.fromLTRB(sx, 0.0, sx + 1.0, image.height.toDouble());
final yv = ((h * calcR * movX) * hWRatio) - hWCorrection;
final ds = (yv * v);
final dr = Rect.fromLTRB(xv * w, 0.0 - ds, xv * w + 1.0, h + ds);
c.drawImageRect(image, sr, dr, ip);
}
}
#override
bool shouldRepaint(_PageTurnEffect oldDelegate) {
return oldDelegate.image != image || oldDelegate.amount.value != amount.value;
}
}