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;
}
Related
I'm trying to create a gridview which works like Stagarredgridview.count but without using any package, i've created it using CustomMultichildLayout but i can't make it scrollable. I know i can use any scrollable like singlechildscrollview or listview for this.
The main problem is infinite height which i can't seem to resolve, i've tried layout builder,expanded and flexible widgets for constraints even boxconstraints from Container but can't resolve infinite height.
Here's the code
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
#override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Column(
children: [
Expanded(
child: OrganicGrid(count: 50),
),
],
),
);
}
}
class OrganicGrid extends StatelessWidget {
final int count;
const OrganicGrid({
required this.count,
super.key,
});
#override
Widget build(BuildContext context) {
return CustomGrid(
children: List.generate(count, (index) => ContainerWidget(id: index + 1)),
);
}
}
class ContainerWidget extends StatelessWidget {
final int id;
const ContainerWidget({
required this.id,
super.key,
});
#override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 50,
decoration: const BoxDecoration(
color: Colors.green,
// border: Border.all(color: Colors.red, width: 2),
),
child: Center(
child: Text(
"Text $id",
style: TextStyle(color: Colors.white),
)),
),
);
}
}
class CustomGrid extends StatelessWidget {
final List<Widget> children;
const CustomGrid({required this.children, super.key});
#override
Widget build(BuildContext context) {
List<Widget> rows = [];
bool oddExist = children.length % 2 != 0;
if (oddExist) {
rows.add(LayoutId(id: 0, child: children[0]));
}
if (oddExist) {
for (int i = 1; i < children.length; i++) {
rows.add(LayoutId(id: i, child: children[i]));
}
} else {
for (int i = 1; i <= children.length; i++) {
rows.add(LayoutId(id: i, child: children[i - 1]));
}
}
return CustomMultiChildLayout(
key: key,
delegate: CustomGridDelegate(numRows: rows.length, oddExist: oddExist),
children: rows,
);
}
}
class CustomGridDelegate extends MultiChildLayoutDelegate {
final int numRows;
final bool oddExist;
CustomGridDelegate({required this.numRows, required this.oddExist});
#override
void performLayout(Size size) {
const double padding = 8;
double width = size.width - padding * 3;
double childHeight = 60;
double dy = 0;
double dx = padding;
void childLayout(int i) {
positionChild(i, Offset(dx, dy));
layoutChild(
i,
BoxConstraints(minWidth: width / 2, maxWidth: width / 2),
);
if (i % 2 == 0) {
dy += childHeight;
dx = padding;
} else {
dx = width / 2;
dx += padding * 2;
}
}
void zerothChildLayout(int i) {
positionChild(i, Offset(dx, dy));
layoutChild(
0,
BoxConstraints(minWidth: width + padding),
);
dy += childHeight;
}
if (oddExist) {
zerothChildLayout(0);
for (int i = 1; i < numRows; i++) {
childLayout(i);
}
} else {
for (int i = 1; i <= numRows; i++) {
childLayout(i);
}
}
}
#override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
}
I've tried layoutbuilder,boxconstraints,expanded,flexible and similar widets without success, the only way it work is by wraping with fixed size container but that is not dynamic.
Any help would be appreciated, you can directly try this code by pasting.
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.
i have found this simple full code
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#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> {
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) {
final currentBuilder = _offsetBuilder;
_offsetBuilder = (Size containerSize) {
return currentBuilder.call(containerSize) + details.delta;
};
setState(() {});
},
child: CustomSingleChildLayout(
delegate: MyCustomSingleChildLayoutDelegate(
canGoOffParentBounds: false,
padding: const EdgeInsets.all(8.0),
offsetBuilder: _offsetBuilder,
),
child: Container(
width: 100,
height: 100,
color: Colors.yellow,
),
),
),
);
}
),
);
}
}
now my widget located at Offset.zero; for first snapshot by it's variable OffsetBuilder _offsetBuilder = (_) => Offset.zero;
Question: How i set OffsetBuilder _offsetBuilder = (_) => Offset.zero; to located at following value by first snapshot or first initialize
last point on the screen (max buttom) - 300
and here the class i use for SingleChildLayoutDelegate
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) {
return BoxConstraints.loose(constraints.biggest).deflate(padding);
}
#override
Offset getPositionForChild(Size size, Size childSize) {
Offset childTopLeft = offsetBuilder.call(childSize);
if (canGoOffParentBounds) {
// no more checks on the position needed
return childTopLeft;
}
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;
}
}
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);
}
}
I want to add a new option in the text selection toolbar, an extra option apart of the classics cut, copy, paste, selectAll.
enter image description here
I used SelectableText but its toolbarOptions just let active/desactive the classic options, not create a new one. So I'm trying using EditableText and creating my own text_selection.dart copying the material text_selection class
I supose there is something wrong when I call my text_selection class because the toolbar doesn't show and there is not any error message.
enter image description here
Here is my widget that use my text_selection class
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'my_text_selection_controls.dart';
class PageContent extends StatefulWidget {
final String page = 'a text just for testing';
PageContent();
#override
_PageContentState createState() => _PageContentState();
}
class _PageContentState extends State<PageContent> {
var textController = new TextEditingController();
FocusNode _textfieldFocusNode;
#override
void initState(){
super.initState();
_textfieldFocusNode = FocusNode();
textController.text = widget.page;
}
#override
void dispose() {
_textfieldFocusNode.dispose();
textController.dispose();
super.dispose();
}
void screenTapped(){
print('calling a callback');
}
#override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: GestureDetector(
onTap: () => screenTapped(),
child: Container(
alignment: Alignment.center,
child:
EditableText(
focusNode: _textfieldFocusNode,
controller: textController,
backgroundCursorColor: Colors.lightGreen,
selectionColor: Colors.blue,
style: TextStyle(color: Colors.black, fontSize: 17),
cursorColor: Colors.blue,
textInputAction: TextInputAction.newline,
maxLines: null,
enableInteractiveSelection: true,
selectionControls: mymaterialTextSelectionControls, //USING MY TEXT SELECTION CLASS
),
color: Color(0xfffdf5e6),
)
),
),
);
}
}
And here is my_text_selection class. Is just a copy of the material class.
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
const double _kHandleSize = 22.0;
// Minimal padding from all edges of the selection toolbar to all edges of the
// viewport.
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
// Padding when positioning toolbar below selection.
const double _kToolbarContentDistanceBelow = 16.0;
const double _kToolbarContentDistance = 8.0;
/// Manages a copy/paste text selection toolbar.
class _TextSelectionToolbar extends StatelessWidget {
const _TextSelectionToolbar({
Key key,
this.handleCut,
this.handleCopy,
this.handlePaste,
this.handleSelectAll,
}) : super(key: key);
final VoidCallback handleCut;
final VoidCallback handleCopy;
final VoidCallback handlePaste;
final VoidCallback handleSelectAll;
#override
Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final List<Widget> items = <Widget>[
if (handleCut != null) FlatButton(child: Text(localizations.cutButtonLabel), onPressed: handleCut),
if (handleCopy != null) FlatButton(child: Text(localizations.copyButtonLabel), onPressed: handleCopy),
if (handlePaste != null) FlatButton(child: Text(localizations.pasteButtonLabel), onPressed: handlePaste),
if (handleSelectAll != null) FlatButton(child: Text(localizations.selectAllButtonLabel), onPressed: handleSelectAll),
];
// If there is no option available, build an empty widget.
if (items.isEmpty) {
return Container(width: 0.0, height: 0.0);
}
return Material(
elevation: 1.0,
child: Container(
height: _kToolbarHeight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
}
/// Centers the toolbar around the given position, ensuring that it remains on
/// screen.
class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
_TextSelectionToolbarLayout(this.screenSize, this.globalEditableRegion, this.position);
/// The size of the screen at the time that the toolbar was last laid out.
final Size screenSize;
/// Size and position of the editing region at the time the toolbar was last
/// laid out, in global coordinates.
final Rect globalEditableRegion;
/// Anchor position of the toolbar, relative to the top left of the
/// [globalEditableRegion].
final Offset position;
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
#override
Offset getPositionForChild(Size size, Size childSize) {
final Offset globalPosition = globalEditableRegion.topLeft + position;
double x = globalPosition.dx - childSize.width / 2.0;
double y = globalPosition.dy - childSize.height;
if (x < _kToolbarScreenPadding)
x = _kToolbarScreenPadding;
else if (x + childSize.width > screenSize.width - _kToolbarScreenPadding)
x = screenSize.width - childSize.width - _kToolbarScreenPadding;
if (y < _kToolbarScreenPadding)
y = _kToolbarScreenPadding;
else if (y + childSize.height > screenSize.height - _kToolbarScreenPadding)
y = screenSize.height - childSize.height - _kToolbarScreenPadding;
return Offset(x, y);
}
#override
bool shouldRelayout(_TextSelectionToolbarLayout oldDelegate) {
return position != oldDelegate.position;
}
}
/// Draws a single text selection handle which points up and to the left.
class _TextSelectionHandlePainter extends CustomPainter {
_TextSelectionHandlePainter({ this.color });
final Color color;
#override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()..color = color;
final double radius = size.width/2.0;
canvas.drawCircle(Offset(radius, radius), radius, paint);
canvas.drawRect(Rect.fromLTWH(0.0, 0.0, radius, radius), paint);
}
#override
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
return color != oldPainter.color;
}
}
class _MaterialTextSelectionControls extends TextSelectionControls {
/// Returns the size of the Material handle.
#override
Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style copy/paste text selection toolbar.
#override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
) {
assert(debugCheckHasMediaQuery(context));
assert(debugCheckHasMaterialLocalizations(context));
// The toolbar should appear below the TextField
// when there is not enough space above the TextField to show it.
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final double toolbarHeightNeeded = MediaQuery.of(context).padding.top
+ _kToolbarScreenPadding
+ _kToolbarHeight
+ _kToolbarContentDistance;
final double availableHeight = globalEditableRegion.top + endpoints.first.point.dy - textLineHeight;
final bool fitsAbove = toolbarHeightNeeded <= availableHeight;
final double y = fitsAbove
? startTextSelectionPoint.point.dy - _kToolbarContentDistance - textLineHeight
: startTextSelectionPoint.point.dy + _kToolbarHeight + _kToolbarContentDistanceBelow;
final Offset preciseMidpoint = Offset(position.dx, y);
return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size,
globalEditableRegion,
preciseMidpoint,
),
child: _TextSelectionToolbar(
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
),
),
);
}
/// Builder for material-style text selection handles.
#override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: Theme.of(context).textSelectionHandleColor
),
),
);
// [handle] is a circle, with a rectangle in the top left quadrant of that
// circle (an onion pointing to 10:30). We rotate [handle] to point
// straight up or up-right depending on the handle type.
switch (type) {
case TextSelectionHandleType.left: // points up-right
return Transform.rotate(
angle: math.pi / 2.0,
child: handle,
);
case TextSelectionHandleType.right: // points up-left
return handle;
case TextSelectionHandleType.collapsed: // points up
return Transform.rotate(
angle: math.pi / 4.0,
child: handle,
);
}
assert(type != null);
return null;
}
/// Gets anchor for material-style text selection handles.
///
/// See [TextSelectionControls.getHandleAnchor].
#override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
switch (type) {
case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0);
case TextSelectionHandleType.right:
return Offset.zero;
default:
return const Offset(_kHandleSize / 2, -4);
}
}
#override
bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless
// everything has already been selected.
final TextEditingValue value = delegate.textEditingValue;
return delegate.selectAllEnabled &&
value.text.isNotEmpty &&
!(value.selection.start == 0 && value.selection.end == value.text.length);
}
}
/// Text selection controls that follow the Material Design specification.
final TextSelectionControls mymaterialTextSelectionControls = _MaterialTextSelectionControls();