Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active February 27, 2025 20:58
Show Gist options
  • Save PlugFox/d9e36528326d59f3429d131e610bf315 to your computer and use it in GitHub Desktop.
Save PlugFox/d9e36528326d59f3429d131e610bf315 to your computer and use it in GitHub Desktop.
CustomClipper with MultiChildLayoutDelegate
/*
* 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