Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created July 9, 2025 18:56
Show Gist options
  • Save slightfoot/2f1fb7aa3fb7c78dfc1f06ad94ad1764 to your computer and use it in GitHub Desktop.
Save slightfoot/2f1fb7aa3fb7c78dfc1f06ad94ad1764 to your computer and use it in GitHub Desktop.
Stack Canvas - Part 1 - by Simon Lightfoot and Alois Daniel :: #HumpdayQandA on 9th July 2025 :: https://www.youtube.com/watch?v=ilvgEENnBjg
// 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 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Idea: https://x.com/aloisdeniel/status/1942685270102409666
void main() {
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with SingleTickerProviderStateMixin {
late StackCanvasController _controller;
@override
void initState() {
super.initState();
_controller = StackCanvasController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
child: DefaultTextStyle.merge(
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.w500,
),
child: StackCanvas(
controller: _controller,
children: [
StackItem(
rect: Rect.fromLTWH(100, -20, 200, 150),
builder: (BuildContext context) => DemoItem(
color: Colors.red,
label: 'Child 1',
),
),
StackItem(
rect: Rect.fromLTWH(-50, 100, 200, 150),
builder: (BuildContext context) => DemoItem(
color: Colors.blue,
label: 'Child 2',
),
),
StackItem(
rect: Rect.fromLTWH(200, 250, 200, 150),
builder: (BuildContext context) => DemoItem(
color: Colors.green,
label: 'Child 3',
),
),
StackItem(
rect: Rect.fromLTWH(500, 25, 200, 150),
builder: (BuildContext context) => DemoItem(
color: Colors.teal,
label: 'Child 4',
),
),
],
),
),
),
);
}
}
class DemoItem extends StatelessWidget {
const DemoItem({
super.key,
required this.color,
required this.label,
});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16.0),
),
child: Center(child: Text(label)),
);
}
}
class StackItem extends StatelessWidget {
const StackItem({
super.key,
required this.rect,
required this.builder,
});
final Rect rect;
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
return Positioned.fromRect(
rect: rect,
child: Builder(builder: builder),
);
}
}
class StackCanvasController extends ChangeNotifier {
StackCanvasController({
Offset initialPosition = Offset.zero,
}) : _origin = initialPosition;
Offset _origin;
Offset get origin => _origin;
set origin(Offset value) {
if (_origin != value) {
_origin = value;
notifyListeners();
}
}
}
class StackCanvas extends StatelessWidget {
const StackCanvas({
super.key,
required this.controller,
required this.children,
});
final StackCanvasController controller;
final List<StackItem> children;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanUpdate: (details) {
controller.origin -= details.delta;
},
child: StackCanvasLayout(
controller: controller,
children: children,
),
);
}
}
class StackCanvasLayout extends MultiChildRenderObjectWidget {
const StackCanvasLayout({
super.key,
required this.controller,
required List<StackItem> super.children,
});
final StackCanvasController controller;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderStackCanvas(controller: controller);
}
@override
void updateRenderObject(BuildContext context, covariant RenderStackCanvas renderObject) {
renderObject.controller = controller;
}
}
class RenderStackCanvas extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, StackParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, StackParentData> {
RenderStackCanvas({
required StackCanvasController controller,
}) : _controller = controller;
StackCanvasController _controller;
StackCanvasController get controller => _controller;
set controller(StackCanvasController value) {
if (_controller != value) {
if (attached) {
_controller.removeListener(_onOriginChanged);
value.addListener(_onOriginChanged);
}
_controller = value;
_onOriginChanged();
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
controller.addListener(_onOriginChanged);
}
@override
void detach() {
controller.removeListener(_onOriginChanged);
super.detach();
}
void _onOriginChanged() {
markNeedsPaint();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! StackParentData) {
child.parentData = StackParentData();
}
}
@override
void performLayout() {
final children = getChildrenAsList();
for (final child in children) {
final parentData = child.parentData as StackParentData;
final childConstraints = BoxConstraints.tightFor(
width: parentData.width!,
height: parentData.height!,
);
child.layout(childConstraints);
parentData.offset = Offset(parentData.left!, parentData.top!);
}
size = constraints.biggest;
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset - _controller.origin);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment