Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created July 2, 2025 18:29
Show Gist options
  • Save slightfoot/2259434ad9da44ae881036226de4134c to your computer and use it in GitHub Desktop.
Save slightfoot/2259434ad9da44ae881036226de4134c to your computer and use it in GitHub Desktop.
Animated Image Grid - Part 2 - by Simon Lightfoot :: #HumpdayQandA on 2nd July 2025 :: https://www.youtube.com/watch?v=C2_bZZHg-ag
// MIT License
//
// Copyright (c) 2025 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';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'photos.dart';
void main() {
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
Future<void>? _futurePrecache;
Future<void> precacheImages() async {
await Future.wait([
for (final item in imageThumbUrls) //
precacheImage(NetworkImage(item), context),
]);
print('debug: precache complete');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_futurePrecache ??= precacheImages();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
color: Colors.black,
child: AnimatedImageGrid(
images: imageThumbUrls,
),
),
);
}
}
class AnimatedImageGrid extends StatefulWidget {
const AnimatedImageGrid({
super.key,
required this.images,
});
final List<String> images;
@override
State<AnimatedImageGrid> createState() => _AnimatedImageGridState();
}
class _AnimatedImageGridState extends State<AnimatedImageGrid> {
final _existingData = <String>[];
final _newData = <String>[];
@override
void initState() {
super.initState();
_existingData.addAll(widget.images.sublist(0, widget.images.length ~/ 2));
_newData.addAll(widget.images.sublist(widget.images.length ~/ 2));
}
void _onAdd() {
if (_newData.isNotEmpty) {
setState(() {
_existingData.insert(0, _newData.removeLast());
});
}
}
void _onDelete(String item) {
setState(() {
_existingData.remove(item);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SingleChildScrollView(
child: AnimatedGridLayout(
children: [
for (final (index, item) in _existingData.indexed) //
GridItem(
key: Key(item),
index: index,
item: item,
onDelete: _onDelete,
),
],
),
),
floatingActionButton: _newData.isEmpty
? null
: FloatingActionButton(
onPressed: _onAdd,
child: Icon(Icons.add),
),
);
}
}
class GridItem extends StatelessWidget {
const GridItem({
super.key,
required this.index,
required this.item,
required this.onDelete,
});
final int index;
final String item;
final void Function(String item) onDelete;
@override
Widget build(BuildContext context) {
// duration: Duration(milliseconds: 200 * index),
// curve: Curves.fastLinearToSlowEaseIn,
return AnimatedGridItem(
id: item,
child: Material(
color: Colors.black,
child: Ink.image(
image: NetworkImage(item),
fit: BoxFit.cover,
child: InkWell(
onTap: () => onDelete(item),
),
),
),
);
}
}
class AnimatedGridItem extends ParentDataWidget<AnimatedGridParentData> {
const AnimatedGridItem({
super.key,
required this.id,
required super.child,
});
final String id;
@override
void applyParentData(RenderObject renderObject) {
final parentData = renderObject.parentData as AnimatedGridParentData;
parentData.id = id;
}
@override
Type get debugTypicalAncestorWidgetClass => AnimatedGridLayout;
}
class AnimatedGridLayout extends MultiChildRenderObjectWidget {
const AnimatedGridLayout({
super.key,
required super.children,
});
@override
RenderAnimatedGridLayout createRenderObject(BuildContext context) {
return RenderAnimatedGridLayout();
}
@override
void updateRenderObject(BuildContext context, RenderAnimatedGridLayout renderObject) {
//renderObject.delegate = delegate;
}
}
class AnimatedGridParentData extends ContainerBoxParentData<RenderBox> {
String? id;
int? index;
@override
String toString() => '${super.toString()}; id=$id';
}
class RenderAnimatedGridLayout extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, AnimatedGridParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, AnimatedGridParentData> {
Ticker? _ticker;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_ticker = Ticker(
_onTick,
debugLabel: 'RenderAnimatedGridLayout#$hashCode',
);
_ticker!.start();
}
void _onTick(Duration elapsed) {
final random = Random();
final children = getChildrenAsList();
for (int index = 0; index < children.length; index++) {
final child = children[index];
final data = childParentData(child);
final rect = rectForChild(index);
data.offset = rect.topLeft.translate(
(5.0 * random.nextDouble()) - 2.5,
(5.0 * random.nextDouble()) - 2.5,
);
}
markNeedsPaint();
}
@override
void detach() {
_ticker?.stop();
super.detach();
}
@override
void setupParentData(covariant RenderBox child) {
if (child.parentData is! AnimatedGridParentData) {
child.parentData = AnimatedGridParentData();
}
}
Rect rectForChild(int index) {
try {
final childWidth = constraints.maxWidth / 3;
final childSize = Size(childWidth, childWidth * 1.5);
final x = (index % 3);
final y = (index ~/ 3);
return Offset(x * childWidth, y * childSize.height) & childSize;
} on StateError {
return Rect.zero;
}
}
@override
void performLayout() {
// input: constraints
final childWidth = constraints.maxWidth / 3;
final childHeight = childWidth * 1.5;
final children = getChildrenAsList();
for (int index = 0; index < children.length; index++) {
final child = children[index];
final rect = rectForChild(index);
child.layout(BoxConstraints.tight(rect.size));
childParentData(child).offset = rect.topLeft;
}
// output: size
size = Size(
constraints.maxWidth,
children.length / 3 * childHeight,
);
}
AnimatedGridParentData childParentData(RenderBox child) {
return (child.parentData as AnimatedGridParentData);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment