Last active
February 27, 2025 20:58
-
-
Save PlugFox/d9e36528326d59f3429d131e610bf315 to your computer and use it in GitHub Desktop.
CustomClipper with MultiChildLayoutDelegate
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* CustomClipper with MultiChildLayoutDelegate | |
* https://gist.github.com/PlugFox/d9e36528326d59f3429d131e610bf315 | |
* https://dartpad.dev?id=d9e36528326d59f3429d131e610bf315 | |
* Mike Matiunin <[email protected]>, 27 February 2025 | |
*/ | |
import 'dart:async'; | |
import 'dart:math' as math; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
void main() => runZonedGuarded<void>( | |
() => runApp(const App()), | |
(error, stackTrace) => | |
print('Top level exception: $error\n$stackTrace'), // ignore: avoid_print | |
); | |
/// {@template app} | |
/// App widget. | |
/// {@endtemplate} | |
class App extends StatefulWidget { | |
/// {@macro app} | |
const App({super.key}); | |
@override | |
State<App> createState() => _AppState(); | |
} | |
class _AppState extends State<App> { | |
final ValueNotifier<double> gap = ValueNotifier(24); | |
final ValueNotifier<double> balance = ValueNotifier(.5); | |
@override | |
void dispose() { | |
super.dispose(); | |
gap.dispose(); | |
balance.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
title: 'Ticket', | |
home: Scaffold( | |
appBar: AppBar(title: const Text('Ticket')), | |
body: SafeArea( | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Column( | |
mainAxisSize: MainAxisSize.max, | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: <Widget>[ | |
Expanded( | |
flex: 3, | |
child: Center( | |
child: FittedBox( | |
alignment: Alignment.center, | |
fit: BoxFit.scaleDown, | |
child: SizedBox( | |
height: 300, | |
width: 200, | |
child: ListenableBuilder( | |
listenable: Listenable.merge([gap, balance]), | |
builder: | |
(context, _) => TicketItem( | |
topChild: ColoredBox( | |
color: Colors.red, | |
child: SizedBox( | |
width: 128, | |
height: math.max(24, 100 * balance.value), | |
child: const Center(child: Text('TOP')), | |
), | |
), | |
bottomChild: ColoredBox( | |
color: Colors.green, | |
child: SizedBox( | |
width: 128, | |
height: math.max( | |
24, | |
100 * (1 - balance.value), | |
), | |
child: const Center(child: Text('BOTTOM')), | |
), | |
), | |
gap: gap.value, | |
color: Colors.lightBlue, | |
), | |
), | |
), | |
), | |
), | |
), | |
const SizedBox(height: 16), | |
Expanded( | |
flex: 1, | |
child: Center( | |
child: FittedBox( | |
alignment: Alignment.center, | |
fit: BoxFit.scaleDown, | |
child: SizedBox( | |
width: 200, | |
height: 72, | |
child: Column( | |
mainAxisSize: MainAxisSize.max, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
Expanded( | |
child: SliderNotifier( | |
notifier: gap, | |
label: 'Gap size', | |
min: 8, | |
max: 64, | |
tooltip: | |
'Change gap size between ' | |
'top and bottom widgets ' | |
'to change ticket shape.', | |
), | |
), | |
const SizedBox(height: 16), | |
Expanded( | |
child: SliderNotifier( | |
notifier: balance, | |
label: 'Balance', | |
min: .25, | |
max: .75, | |
tooltip: | |
'Change balance between top ' | |
'and bottom widgets to change ' | |
'distribution of height.', | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
); | |
} | |
class SliderNotifier extends StatelessWidget { | |
const SliderNotifier({ | |
required this.notifier, | |
required this.label, | |
required this.tooltip, | |
this.min = 0, | |
this.max = 1, | |
super.key, // ignore: unused_element | |
}); | |
final ValueNotifier<double> notifier; | |
final String label; | |
final String tooltip; | |
final double min; | |
final double max; | |
@override | |
Widget build(BuildContext context) => Tooltip( | |
message: tooltip, | |
child: Row( | |
mainAxisSize: MainAxisSize.max, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: <Widget>[ | |
SizedBox(width: 64, child: Text(label)), | |
const SizedBox(width: 8), | |
Expanded( | |
child: ValueListenableBuilder<double>( | |
valueListenable: notifier, | |
builder: | |
(context, value, _) => Slider.adaptive( | |
label: label, | |
value: value, | |
onChanged: (value) => notifier.value = value.clamp(min, max), | |
min: min, | |
max: max, | |
), | |
), | |
), | |
], | |
), | |
); | |
} | |
class TicketItem extends StatefulWidget { | |
const TicketItem({ | |
required this.topChild, | |
required this.bottomChild, | |
this.gap = 24, | |
this.color = Colors.lightBlue, | |
super.key, | |
}); | |
final Widget topChild; | |
final Widget bottomChild; | |
final double gap; | |
final Color color; | |
@override | |
State<TicketItem> createState() => _TicketItemState(); | |
} | |
class _TicketItemState extends State<TicketItem> { | |
final ValueNotifier<double> gap = ValueNotifier(0); | |
final ValueNotifier<double> offset = ValueNotifier(0); | |
late final layout = _TicketLayout( | |
gap: gap, | |
offsetChanged: (value) => offset.value = value, | |
); | |
late final clipper = _TicketClipper(gap: gap, offset: offset); | |
@override | |
void initState() { | |
super.initState(); | |
gap.value = widget.gap; | |
} | |
@override | |
void didUpdateWidget(covariant TicketItem oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
gap.value = widget.gap; | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
gap.dispose(); | |
offset.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => ClipPath( | |
clipper: clipper, | |
child: ColoredBox( | |
color: widget.color, | |
child: CustomMultiChildLayout( | |
delegate: layout, | |
children: <Widget>[ | |
LayoutId(id: 1, child: widget.topChild), | |
// --- widget.gap --- // | |
LayoutId(id: 2, child: widget.bottomChild), | |
], | |
), | |
), | |
); | |
} | |
class _TicketLayout extends MultiChildLayoutDelegate { | |
_TicketLayout({ | |
required ValueListenable<double> gap, | |
required ValueChanged<double> offsetChanged, | |
}) : _gap = gap, | |
_offsetChanged = offsetChanged, | |
super(relayout: gap); | |
final ValueListenable<double> _gap; | |
final ValueChanged<double> _offsetChanged; | |
@override | |
void performLayout(Size size) { | |
final gap = _gap.value; | |
final halfHeight = (size.height - gap) / 2; | |
// Лэйаутим детей с ограничениями по своей половине | |
final top = layoutChild( | |
1, | |
BoxConstraints.loose(Size(size.width, halfHeight)), | |
); | |
final bottom = layoutChild( | |
2, | |
BoxConstraints.loose(Size(size.width, halfHeight)), | |
); | |
// Центрирование каждого элемента в своей половине | |
final topX = (size.width - top.width) / 2; | |
final topY = (halfHeight - top.height) / 2; | |
final bottomX = (size.width - bottom.width) / 2; | |
final bottomY = (halfHeight - bottom.height) / 2 + halfHeight + gap; | |
_offsetChanged((topY + top.height + bottomY) / 2); | |
// Размещаем детей | |
positionChild(1, Offset(topX, topY)); | |
positionChild(2, Offset(bottomX, bottomY)); | |
} | |
@override | |
bool shouldRelayout(covariant _TicketLayout oldDelegate) => | |
!identical(_gap, oldDelegate._gap); | |
} | |
class _TicketClipper extends CustomClipper<Path> { | |
_TicketClipper({ | |
required ValueListenable<double> gap, | |
required ValueListenable<double> offset, | |
}) : _gap = gap, | |
_offset = offset, | |
super(reclip: Listenable.merge([gap, offset])); | |
final ValueListenable<double> _gap; | |
final ValueListenable<double> _offset; | |
@override | |
Path getClip(Size size) { | |
final gap = _gap.value; | |
final notch = _offset.value; | |
final widgetRect = RRect.fromRectAndRadius( | |
Rect.fromPoints(Offset.zero, Offset(size.width, size.height)), | |
Radius.circular(gap / 2), | |
); | |
return Path.combine( | |
PathOperation.difference, | |
Path()..addRRect(widgetRect), | |
Path() | |
// Левый вырез | |
..addOval( | |
Rect.fromCenter( | |
// Центр левой окружности | |
center: Offset(0, notch), | |
width: gap, | |
height: gap, | |
), | |
) | |
// Правый вырез | |
..addOval( | |
Rect.fromCenter( | |
// Центр правой окружности | |
center: Offset(size.width, notch), | |
width: gap, | |
height: gap, | |
), | |
) | |
// Прямоугольный вырез по горизонтали | |
..addRect( | |
Rect.fromCenter( | |
center: Offset(size.width / 2, notch), | |
width: size.width, | |
height: (gap / 8).clamp(4, 16), | |
), | |
), | |
); | |
} | |
@override | |
bool shouldReclip(covariant _TicketClipper oldClipper) => | |
!identical(_gap, oldClipper._gap) || | |
!identical(_offset, oldClipper._offset); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment