Last active
July 12, 2024 17:08
-
-
Save pskink/468b0ed8859a85f94f0040c552e3a488 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
import 'package:collection/collection.dart'; | |
void main() => runApp(MaterialApp(home: Scaffold(body: Foo()))); | |
class Foo extends StatefulWidget { | |
@override | |
State<Foo> createState() => _FooState(); | |
} | |
class _FooState extends State<Foo> { | |
static const kGrid = 64.0; | |
static final kNormalizedRect = Rect.fromCircle(center: Offset.zero, radius: 1); | |
late final container = const Rect.fromLTWH(0, 0, 5, 6); | |
late final items = [ | |
(const Rect.fromLTWH(0, 0, 2, 2), Colors.indigo, 'indigo'), | |
(const Rect.fromLTWH(2, 0, 2, 1), Colors.orange.shade800, 'orange'), | |
(const Rect.fromLTWH(4, 0, 1, 3), Colors.pink, 'pink'), | |
(const Rect.fromLTWH(0, 2, 3, 1), Colors.deepPurple, 'deep purple'), | |
(const Rect.fromLTWH(3, 1, 1, 2), Colors.red.shade800, 'red'), | |
(const Rect.fromLTWH(2, 1, 1, 1), Colors.teal, 'teal'), | |
].map((r) => Item(initialRect: r.$1, color: r.$2, debugName: r.$3, container: container)).toList(); | |
final sizerData = [ | |
(Alignment.centerLeft, (Offset d) => EdgeInsets.only(left: -d.dx)), | |
(Alignment.topCenter, (Offset d) => EdgeInsets.only(top: -d.dy)), | |
(Alignment.centerRight, (Offset d) => EdgeInsets.only(right: d.dx)), | |
(Alignment.bottomCenter, (Offset d) => EdgeInsets.only(bottom: d.dy)), | |
]; | |
Item? _activeItem; | |
bool layoutAll = false; | |
bool clamp = true; | |
@override | |
Widget build(BuildContext context) { | |
return Center( | |
child: SingleChildScrollView( | |
child: SizedBox( | |
width: container.width * kGrid, | |
child: Column( | |
children: [ | |
Container( | |
color: Colors.green.shade200, | |
child: const Padding(padding: EdgeInsets.all(6), child: Text('''move or/and resize any item below\nyou can control the item's layout algorithm and clamping inside the grid by using the checkboxes below''')), | |
), | |
CheckboxListTile( | |
value: layoutAll, | |
onChanged: (v) => setState(() => layoutAll = v!), | |
title: const Text('layout all items'), | |
), | |
CheckboxListTile( | |
value: clamp, | |
onChanged: (v) => setState(() => clamp = v!), | |
title: const Text('clamp items inside container'), | |
), | |
SizedBox.fromSize( | |
size: container.size * kGrid, | |
child: Stack( | |
children: [ | |
Positioned.fill( | |
child: GridPaper( | |
subdivisions: 1, | |
interval: kGrid, | |
color: Colors.black38, | |
child: GestureDetector( | |
onTap: () => setState(() => _activeItem = null), | |
), | |
), | |
), | |
...items.map(_itemBuilder), | |
], | |
), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
Widget _itemBuilder(Item item) { | |
final isActive = item == _activeItem; | |
return AnimatedPositioned.fromRect( | |
key: ObjectKey(item), | |
duration: Durations.short4, | |
rect: item.rect * kGrid, | |
child: Stack( | |
children: [ | |
// item itself | |
Positioned.fill( | |
child: AnimatedContainer( | |
duration: Durations.medium4, | |
foregroundDecoration: BoxDecoration( | |
color: isActive? Colors.grey.withOpacity(0.8) : null, | |
), | |
decoration: BoxDecoration( | |
boxShadow: isActive? const [BoxShadow(blurRadius: 4, offset: Offset(3, 3))] : null, | |
), | |
child: GestureDetector( | |
onTap: () => setState(() { | |
_restack(item); | |
_activeItem = isActive? null : item; | |
}), | |
onPanStart: (d) => setState(() { | |
_restack(item); | |
_activeItem = item; | |
}), | |
onPanUpdate: (d) { | |
if (item.moveBy(d.delta / kGrid, clamp)) { | |
debugPrint('"${item.debugName}" moved to ${item.location(false)}'); | |
_relayout(container, item, items, layoutAll); | |
setState(() {}); | |
} | |
}, | |
onPanEnd: (d) => item.settle(), | |
child: item.build(isActive), | |
), | |
), | |
), | |
// four sizers | |
...sizerData.map((sizerRecord) { | |
final (alignment, insetBuilder) = sizerRecord; | |
return ClipRect( | |
child: Align( | |
alignment: alignment, | |
child: SizedBox.fromSize( | |
size: const Size.square(kGrid / sqrt2), | |
child: AnimatedSlide( | |
duration: Durations.medium4, | |
offset: alignment.withinRect(kNormalizedRect) * (isActive? 0.5 : 1), | |
curve: Curves.easeOut, | |
child: DecoratedBox( | |
decoration: BoxDecoration( | |
color: item.color.withOpacity(0.5), | |
border: Border.all(color: Colors.black38, width: 1), | |
shape: BoxShape.circle, | |
), | |
child: GestureDetector( | |
onPanUpdate: (d) { | |
if(item.inflateBy(insetBuilder(d.delta / kGrid), clamp)) { | |
debugPrint('"${item.debugName}" resized to ${item.location(true)}'); | |
_relayout(container, item, items, layoutAll); | |
setState(() {}); | |
} | |
}, | |
onPanEnd: (d) => item.settle(), | |
), | |
), | |
), | |
), | |
), | |
); | |
}), | |
], | |
), | |
); | |
} | |
_relayout(Rect container, Item fixedItem, List<Item> items, bool layoutAll) { | |
final fixedItemRect = fixedItem.rect; | |
final remainingItems = items.where((i) => i != fixedItem).toList(); | |
final itemsToLayout = layoutAll? remainingItems : remainingItems.where((i) => i._rect.overlaps(fixedItemRect)); | |
final itemsNotToLayout = layoutAll? <Item>[] : remainingItems.where((i) => !i._rect.overlaps(fixedItemRect)); | |
int byLongestSide(Item a, Item b) => -a._rect.longestSide.compareTo(b._rect.longestSide); | |
// int byDiagonal(Item a, Item b) => -a._rect.size.bottomRight(Offset.zero).distance.compareTo(b._rect.size.bottomRight(Offset.zero).distance); | |
// int byHeight(Item a, Item b) => -a._rect.height.compareTo(b._rect.height); | |
final sortedItems = itemsToLayout.sorted(byLongestSide); | |
// debugPrint('sortedItems: ${sortedItems.map((item) => item.debugName)}}'); | |
final placedItems = <Item, Rect>{ | |
fixedItem: fixedItemRect, | |
for (final item in itemsNotToLayout) | |
item: item._rect, | |
}; | |
itemLoop: | |
for (final item in sortedItems) { | |
for (double t = container.top; t < container.bottom - item._rect.height + 1; t++) { | |
for (double l = container.left; l < container.right - item._rect.width + 1; l++) { | |
final r = Rect.fromLTWH(l, t, item._rect.width, item._rect.height); | |
final goodPlaceToLay = placedItems.values.every((i) => !i.overlaps(r)); | |
if (goodPlaceToLay) { | |
// debugPrint('good place to lay for ${item.debugName} is $r'); | |
item._rect = r; | |
placedItems[item] = r; | |
continue itemLoop; | |
} | |
} | |
} | |
debugPrint('no space for ${item.debugName}'); | |
} | |
} | |
void _restack(Item item) { | |
items | |
..remove(item) | |
..add(item); | |
debugPrint('new order: ${items.map((item) => item.debugName)}'); | |
} | |
} | |
extension RectUtilExtension on Rect { | |
Rect snap(double grid) { | |
final l = (left / grid).roundToDouble() * grid; | |
final t = (top / grid).roundToDouble() * grid; | |
final r = (right / grid).roundToDouble() * grid; | |
final b = (bottom / grid).roundToDouble() * grid; | |
return Rect.fromLTRB(l, t, r, b); | |
} | |
Rect clamp(Rect container) { | |
// assert(width <= container.width && height <= container.height); | |
double l = max(left, container.left); | |
double t = max(top, container.top); | |
if (right > container.right) l -= right - container.right; | |
if (bottom > container.bottom) t -= bottom - container.bottom; | |
// l == left && t == top means that no clamping was done so return 'this' Rect | |
return l == left && t == top? this : Rect.fromLTWH(l, t, width, height); | |
} | |
Rect operator *(double operand) => Rect.fromLTRB(left * operand, top * operand, right * operand, bottom * operand); | |
} | |
class Item { | |
Item({ | |
required Rect initialRect, | |
required this.color, | |
required this.debugName, | |
required this.container, | |
}) : _rect = initialRect; | |
Rect _rect; | |
final Color color; | |
final String debugName; | |
final Rect container; | |
// snapped Rect | |
Rect get rect => _rect.snap(1); | |
bool moveBy(Offset delta, bool clamp) { | |
final old = rect; | |
_rect = _rect.shift(delta); | |
if (clamp) _clamp(old, false); | |
return old != rect; | |
} | |
bool inflateBy(EdgeInsets insets, bool clamp) { | |
final old = rect; | |
_rect = insets.inflateRect(_rect); | |
if (clamp) _clamp(old, true); | |
return old != rect; | |
} | |
_clamp(Rect old, bool resize) { | |
if (old == rect) return; | |
if (resize) { | |
if (_rect.expandToInclude(container) != container) { | |
_rect = old; | |
} | |
} else { | |
_rect = _rect.clamp(container); | |
} | |
} | |
settle() => _rect = rect; | |
Widget build(bool isActive) => Container( | |
padding: const EdgeInsets.all(4), | |
decoration: BoxDecoration( | |
gradient: RadialGradient( | |
colors: [Color.alphaBlend(Colors.white30, color), color], | |
radius: 1, | |
), | |
), | |
child: FittedBox(child: Text(debugName, style: const TextStyle(color: Colors.white70))), | |
); | |
String location(bool reportSize) { | |
final r = rect; | |
final position = r.topLeft; | |
final size = r.size; | |
final positionStr = '(${position.dx.toInt()},${position.dy.toInt()})'; | |
return switch (reportSize) { | |
true => '${size.width.toInt()}x${size.height.toInt()} at $positionStr', | |
false => positionStr, | |
}; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment