Last active
August 19, 2025 04:25
-
-
Save davidhicks980/ba613e9606a391c3da51207cea31e932 to your computer and use it in GitHub Desktop.
ThresholdChild usage
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' 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