Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active August 7, 2025 18:37
Show Gist options
  • Save slightfoot/20e4715f2ea863faef1b4d46f6964d12 to your computer and use it in GitHub Desktop.
Save slightfoot/20e4715f2ea863faef1b4d46f6964d12 to your computer and use it in GitHub Desktop.
Stack Canvas - with Active Rebulding - Part 3 - by Simon Lightfoot :: #HumpdayQandA on 6th @Auguest July 2025 :: https://www.youtube.com/watch?v=HV634FxxdPs
// 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:collection';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase;
/// Idea: https://x.com/aloisdeniel/status/1942685270102409666
const debugTestClippingInset = 50.0;
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 RenderObjectWidget {
const StackCanvasLayout({
super.key,
required this.controller,
required this.children,
});
final StackCanvasController controller;
final List<StackItem> children;
@override
RenderObjectElement createElement() => StackCanvasElement(this);
@protected
bool updateShouldRebuild(covariant StackCanvasLayout oldWidget) => true;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderStackCanvas(controller: controller);
}
@override
void updateRenderObject(BuildContext context, covariant RenderStackCanvas renderObject) {
renderObject.controller = controller;
}
}
class StackCanvasElement extends RenderObjectElement {
StackCanvasElement(StackCanvasLayout super.widget);
@override
RenderStackCanvas get renderObject => super.renderObject as RenderStackCanvas;
@override
StackCanvasLayout get widget => super.widget as StackCanvasLayout;
@override
BuildScope get buildScope => _buildScope;
late final _buildScope = BuildScope(scheduleRebuild: _scheduleRebuild);
bool _deferredCallbackScheduled = false;
void _scheduleRebuild() {
if (_deferredCallbackScheduled) {
return;
}
final bool deferMarkNeedsLayout = switch (SchedulerBinding.instance.schedulerPhase) {
SchedulerPhase.idle || SchedulerPhase.postFrameCallbacks => true,
SchedulerPhase.transientCallbacks ||
SchedulerPhase.midFrameMicrotasks ||
SchedulerPhase.persistentCallbacks => false,
};
if (!deferMarkNeedsLayout) {
renderObject.scheduleLayoutCallback();
return;
}
_deferredCallbackScheduled = true;
SchedulerBinding.instance.scheduleFrameCallback(_frameCallback);
}
void _frameCallback(Duration timestamp) {
_deferredCallbackScheduled = false;
if (mounted) {
renderObject.scheduleLayoutCallback();
}
}
var _children = <Element>[];
/// The current list of children of this element.
///
/// This list is filtered to hide elements that have been forgotten (using
/// [forgetChild]).
Iterable<Element> get children =>
_children.where((Element child) => !_forgottenChildren.contains(child));
// We keep a set of forgotten children to avoid O(n^2) work walking _children
// repeatedly to remove children.
final Set<Element> _forgottenChildren = HashSet<Element>();
@override
void visitChildren(ElementVisitor visitor) {
for (final Element child in _children) {
if (!_forgottenChildren.contains(child)) {
visitor(child);
}
}
}
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
renderObject.elementCallback = elementCallback;
}
@override
void update(StackCanvasLayout newWidget) {
super.update(newWidget);
renderObject.elementCallback = elementCallback;
if (newWidget.updateShouldRebuild(widget)) {
_needsBuild = true;
renderObject.scheduleLayoutCallback();
}
}
@override
void markNeedsBuild() {
renderObject.scheduleLayoutCallback();
_needsBuild = true;
}
@override
void performRebuild() {
renderObject.scheduleLayoutCallback();
_needsBuild = true;
super.performRebuild();
}
@override
void unmount() {
renderObject.elementCallback = null;
super.unmount();
}
Rect? _currentViewport;
bool _needsBuild = true;
void elementCallback(Rect viewport) {
if (_needsBuild || _currentViewport != viewport) {
owner!.buildScope(this, () {
try {
// Loop over all widget.children and build the ones that are visible
final newChildren = widget.children.where((child) {
return child.rect.overlaps(viewport);
}).toList();
_children = updateChildren(
_children,
newChildren,
forgottenChildren: _forgottenChildren,
);
_forgottenChildren.clear();
} finally {
_needsBuild = false;
_currentViewport = viewport;
}
});
}
}
@override
void forgetChild(Element child) {
_forgottenChildren.add(child);
super.forgetChild(child);
}
@override
void insertRenderObjectChild(RenderBox child, IndexedSlot<Element?> slot) {
renderObject.insert(child, after: slot.value?.renderObject as RenderBox?);
}
@override
void moveRenderObjectChild(
RenderBox child,
IndexedSlot<Element?> oldSlot,
IndexedSlot<Element?> newSlot,
) {
renderObject.move(child, after: newSlot.value?.renderObject as RenderBox?);
}
@override
void removeRenderObjectChild(RenderBox child, Object? slot) {
renderObject.remove(child);
}
}
class RenderStackCanvas extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, StackParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, StackParentData>,
RenderObjectWithLayoutCallbackMixin {
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();
}
}
void Function(Rect viewport)? _elementCallback;
set elementCallback(void Function(Rect viewport)? value) {
if (_elementCallback != value) {
_elementCallback = value;
if (_elementCallback != null) {
scheduleLayoutCallback();
}
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
controller.addListener(_onOriginChanged);
}
@override
void detach() {
controller.removeListener(_onOriginChanged);
super.detach();
}
void _onOriginChanged() {
scheduleLayoutCallback();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! StackParentData) {
child.parentData = StackParentData();
}
}
@override
void layoutCallback() {
final viewport = (_controller.origin & constraints.biggest).deflate(debugTestClippingInset);
if (_elementCallback != null) {
_elementCallback!(viewport);
}
}
@override
void performLayout() {
runLayoutCallback();
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);
if (debugPaintSizeEnabled) {
context.canvas.drawRect(
(Offset.zero & size).deflate(debugTestClippingInset),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..color = Color(0xFFFF00FF),
);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment