Skip to content

Instantly share code, notes, and snippets.

@davidhicks980
Last active August 19, 2025 04:25
Show Gist options
  • Save davidhicks980/ba613e9606a391c3da51207cea31e932 to your computer and use it in GitHub Desktop.
Save davidhicks980/ba613e9606a391c3da51207cea31e932 to your computer and use it in GitHub Desktop.
ThresholdChild usage
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const ThresholdDemoApp());
}
class ThresholdDemoApp extends StatelessWidget {
const ThresholdDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Threshold Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const Scaffold(body: ThresholdDemo()),
);
}
}
class ThresholdDemo extends StatelessWidget {
const ThresholdDemo({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text("H. P. Lovecraft Quotes"),
Zippy(
content: Container(
padding: const EdgeInsets.all(16.0),
color: Colors.lightBlueAccent,
alignment: Alignment.topCenter,
child: const Text(
'Cats are the runes of beauty, invincibility, wonder, pride, freedom, coldness, self-sufficiency, and dainty individuality — the qualities of sensitive, enlightened, mentally developed, pagan, cynical, poetic, philosophic, dispassionate, reserved, independent, Nietzschean, unbroken, civilised, master-class men.'),
),
),
Zippy(
content: Container(
padding: const EdgeInsets.all(16.0),
color: Colors.lightGreenAccent,
alignment: Alignment.topCenter,
child: const Text('The dog is a peasant and the cat is a gentleman.'),
),
),
],
);
}
}
class Zippy extends StatefulWidget {
const Zippy({
super.key,
this.content,
});
final Widget? content;
@override
State<Zippy> createState() => _ZippyState();
}
class _ZippyState extends State<Zippy> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
static const linearGradient = LinearGradient(
colors: [
ui.Color.fromARGB(255, 0, 0, 0),
ui.Color.fromARGB(255, 0, 0, 0),
ui.Color.fromARGB(0, 255, 255, 255),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.7, 1],
);
static const endGradient = LinearGradient(
colors: [
ui.Color.fromARGB(255, 0, 0, 0),
ui.Color.fromARGB(255, 0, 0, 0),
ui.Color.fromARGB(0, 255, 255, 255),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [1, 1, 1],
);
bool showOverflow = false;
@override
void initState() {
super.initState();
_controller = AnimationController.unbounded(vsync: this);
}
@override
void dispose() {
_controller
..stop()
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final content = widget.content!;
final button = Stack(
children: [
Positioned(
right: 8,
top: 0,
child: IconButton(
onPressed: _handlePress,
icon: showOverflow ? const Icon(Icons.expand_less) : const Icon(Icons.expand_more),
),
),
],
);
return GestureDetector(
onTap: _handlePress,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: AnimatedBuilder(
animation: _controller,
child: content,
builder: (BuildContext context, Widget? child) {
return ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: _buildGradient,
child: ThresholdChild(
threshold: 75,
ratio: _controller.value,
thresholdChild: button,
child: child!,
),
);
},
),
),
);
}
void _handlePress() {
setState(() {
showOverflow = !showOverflow;
});
if (showOverflow) {
_controller.animateTo(
1,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 300),
);
} else {
_controller.animateTo(
0,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 300),
);
}
}
ui.Shader _buildGradient(Rect bounds) {
if (bounds.height > 74) {
return linearGradient.lerpTo(endGradient, _controller.value)!.createShader(bounds);
}
return endGradient.createShader(bounds);
}
}
/// Slots used for the children of [ThresholdChild] and [RenderThresholdExpansionTile].
enum _ThresholdTileSlot {
child,
thresholdChild,
}
/// A widget that shows a [thresholdChild] atop a [child] when the [child]
/// exceeds a specified [threshold] height.
///
/// The [ratio] determines the amount of the [child] to show above the
/// [threshold]. For example, given a [threshold] of 100 and a child that is 200
/// logical pixels tall:
///
/// - If the [ratio] is 0, the [child] will be clipped to 100 logical pixels.
/// - If the [ratio] is 0.5, the [child] will be clipped to 150 logical pixels.
/// - If the [ratio] is 1, the [child] will not be clipped at all.
class ThresholdChild extends SlottedMultiChildRenderObjectWidget<_ThresholdTileSlot, RenderBox> {
const ThresholdChild({
super.key,
required this.child,
required this.ratio,
required this.threshold,
this.thresholdChild = const SizedBox.shrink(),
});
final Widget child;
final Widget thresholdChild;
final double threshold;
final double ratio;
@override
Iterable<_ThresholdTileSlot> get slots => _ThresholdTileSlot.values;
@override
Widget? childForSlot(_ThresholdTileSlot slot) {
return switch (slot) {
_ThresholdTileSlot.child => child,
_ThresholdTileSlot.thresholdChild => thresholdChild,
};
}
// The [createRenderObject] and [updateRenderObject] methods configure the
// [RenderObject] backing this widget with the configuration of the widget.
// They do not need to do anything with the children of the widget, though.
// The children of the widget are automatically configured on the
// [RenderObject] by [SlottedRenderObjectElement.mount] and
// [SlottedRenderObjectElement.update].
@override
SlottedContainerRenderObjectMixin<_ThresholdTileSlot, RenderBox> createRenderObject(
BuildContext context,
) {
return RenderThresholdExpansionTile(
threshold: threshold,
ratio: ratio,
);
}
@override
void updateRenderObject(
BuildContext context,
SlottedContainerRenderObjectMixin<_ThresholdTileSlot, RenderBox> renderObject,
) {
(renderObject as RenderThresholdExpansionTile)
..threshold = threshold
..ratio = ratio;
}
}
/// A render object that demonstrates the usage of
/// [SlottedContainerRenderObjectMixin] by providing slots for two children that
/// will be arranged diagonally.
class RenderThresholdExpansionTile extends RenderBox
with SlottedContainerRenderObjectMixin<_ThresholdTileSlot, RenderBox> {
RenderThresholdExpansionTile({required double threshold, required double ratio})
: _threshold = threshold,
_ratio = ratio;
double get threshold => _threshold;
double _threshold = 0;
set threshold(double value) {
if (_threshold == value) {
return;
}
_threshold = value;
markNeedsLayout();
}
double get ratio => _ratio;
double _ratio = 0;
set ratio(double value) {
if (_ratio == value) {
return;
}
_ratio = value;
markNeedsLayout();
}
final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
bool needsExpansion = false;
// Getters to simplify accessing the slotted children.
RenderBox get _contentSlot => childForSlot(_ThresholdTileSlot.child)!;
RenderBox? get _thresholdChildSlot => childForSlot(_ThresholdTileSlot.thresholdChild);
@override
bool get sizedByParent => false;
// Returns children in hit test order.
@override
Iterable<RenderBox> get children {
final RenderBox? content = childForSlot(_ThresholdTileSlot.child);
return <RenderBox>[
if (_thresholdChildSlot != null) _thresholdChildSlot!,
if (content != null) content,
];
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final contentSize = _contentSlot.getDryLayout(constraints);
final thresholdChildSize = _thresholdChildSlot?.getDryLayout(constraints) ?? Size.zero;
final childSize = Size(
math.max(contentSize.width, thresholdChildSize.width),
math.max(contentSize.height, thresholdChildSize.height),
);
final childHeight = childSize.height;
if (childHeight <= _threshold) {
return childSize;
}
if (_ratio == 0) {
return Size(childSize.width, ui.clampDouble(childHeight, 0, _threshold));
}
final excess = childSize.height - _threshold;
final desired = _threshold + excess * _ratio;
return Size(childSize.width, desired);
}
@override
double computeMinIntrinsicWidth(double height) {
final contentWidth = _contentSlot.computeMinIntrinsicWidth(height);
final thresholdChildWidth = _thresholdChildSlot?.computeMinIntrinsicWidth(height) ?? 0;
return math.max(contentWidth, thresholdChildWidth);
}
@override
double computeMaxIntrinsicWidth(double height) {
final contentWidth = _contentSlot.computeMaxIntrinsicWidth(height);
final thresholdChildWidth = _thresholdChildSlot?.computeMaxIntrinsicWidth(height) ?? 0;
return math.max(contentWidth, thresholdChildWidth);
}
@override
double computeMinIntrinsicHeight(double width) {
final contentHeight = _contentSlot.computeMinIntrinsicHeight(width);
final thresholdChildHeight = _thresholdChildSlot?.computeMinIntrinsicHeight(width);
final height = math.max(contentHeight, thresholdChildHeight ?? 0);
return lerpThreshold(height);
}
@override
double computeMaxIntrinsicHeight(double width) {
final contentHeight = _contentSlot.computeMaxIntrinsicHeight(width);
final thresholdChildHeight = _thresholdChildSlot?.computeMaxIntrinsicHeight(width);
final height = math.max(contentHeight, thresholdChildHeight ?? 0);
return lerpThreshold(height);
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
final contenntDistance = _contentSlot.getDistanceToActualBaseline(baseline);
final thresholdChildDistance = _thresholdChildSlot?.getDistanceToActualBaseline(baseline);
final distance = math.max(contenntDistance ?? 0, thresholdChildDistance ?? 0);
return lerpThreshold(distance);
}
@override
double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
final contentDistance = _contentSlot.computeDryBaseline(constraints, baseline);
final thresholdChildDistance = _thresholdChildSlot?.computeDryBaseline(constraints, baseline);
final distance = math.max(contentDistance ?? 0, thresholdChildDistance ?? 0);
return lerpThreshold(distance);
}
@override
void performLayout() {
_contentSlot.layout(constraints, parentUsesSize: true);
final contentSize = _contentSlot.size;
needsExpansion = contentSize.height > _threshold;
double childHeight = contentSize.height;
if (needsExpansion) {
childHeight = lerpThreshold(childHeight);
}
if (needsExpansion) {
_thresholdChildSlot?.layout(
constraints.tighten(height: childHeight),
);
}
size = Size(contentSize.width, childHeight);
}
double lerpThreshold(double height) {
if (_threshold >= height) {
return height;
}
if (_ratio == 0) {
return _threshold;
}
return ui.lerpDouble(_threshold, height, _ratio)!;
}
@override
void paint(PaintingContext context, Offset offset) {
// Paint the children at the offset calculated during layout.
if (needsExpansion && _ratio < 1) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Rect.fromLTWH(0, 0, size.width, size.height),
(PaintingContext context, Offset offset) {
context.paintChild(_contentSlot, offset);
},
oldLayer: _clipRectLayer.layer,
);
} else {
context.paintChild(_contentSlot, offset);
}
if (needsExpansion) {
final RenderBox? thresholdChild = _thresholdChildSlot;
if (thresholdChild != null) {
context.paintChild(_thresholdChildSlot!, offset);
}
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final childList = children.toList();
if (!needsExpansion && childList.length == 2) {
childList.removeAt(0);
}
for (final RenderBox child in childList.toList()) {
final BoxParentData parentData = child.parentData! as BoxParentData;
final bool isHit = result.addWithPaintOffset(
offset: parentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - parentData.offset);
return child.hitTest(result, position: transformed);
},
);
if (isHit) {
return true;
}
}
return false;
}
@override
void dispose() {
_clipRectLayer.layer?.remove();
_clipRectLayer.layer = null;
super.dispose();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment