Skip to content

Instantly share code, notes, and snippets.

@hectorAguero
Created October 20, 2025 20:38
Show Gist options
  • Select an option

  • Save hectorAguero/2af6e3329eac61f0b5818df4f5f3f407 to your computer and use it in GitHub Desktop.

Select an option

Save hectorAguero/2af6e3329eac61f0b5818df4f5f3f407 to your computer and use it in GitHub Desktop.
main.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dynamic PageView Height Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const Scaffold(
body: SafeArea(child: DynamicPagerDemo()),
),
);
}
}
class DynamicPagerDemo extends StatefulWidget {
const DynamicPagerDemo({super.key});
@override
State<DynamicPagerDemo> createState() => _DynamicPagerDemoState();
}
class _DynamicPagerDemoState extends State<DynamicPagerDemo> {
final PageController _pageController = PageController();
final EdgeInsets _pagePadding =
const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
late final List<TicketData> _items;
late final List<bool> _selected;
late final List<bool> _expanded;
// Height we animate to for the PageView viewport.
double _pageHeight = 236;
// Which page is currently visible.
int _currentIndex = 0;
@override
void initState() {
super.initState();
_items = [
TicketData(
title: 'Economy Saver',
description:
'A budget-friendly ticket with limited flexibility. Includes '
'one personal item. Optional add-ons available for baggage.',
),
TicketData(
title: 'Economy Flex',
description:
'Extra flexibility with one checked bag included. Free changes '
'within fare rules. Great for short trips and weekend getaways.',
),
TicketData(
title: 'Premium',
description:
'Wider seats, priority boarding, and extra legroom. Includes '
'two carry-on items and one checked bag. Lounge access where '
'available. Hot meal service on flights beyond 2 hours.',
),
TicketData(
title: 'Business',
description:
'Fully reclining seats, premium dining, and lounge access. '
'Changeable with minimal fees. Priority everything. Perfect for '
'red-eye and long-haul comfort.',
),
TicketData(
title: 'First Class',
description:
'The highest level of comfort. Suite-like privacy, dedicated '
'service, premium dining, and generous baggage allowance. '
'Ideal for special occasions and maximum flexibility.',
),
];
_selected = List<bool>.filled(_items.length, false);
_expanded = List<bool>.filled(_items.length, false);
// Measure once after first frame to set initial height.
WidgetsBinding.instance.addPostFrameCallback((_) {
_requestMeasure();
});
}
void _onPageChanged(int index) {
setState(() {
_currentIndex = index;
});
_requestMeasure();
}
// Whenever content may have changed size (expand/collapse/select/unselect),
// request a re-measure of the current page.
void _requestMeasure() {
// No-op here; the actual measurement happens via the offstage probe below.
// Calling setState ensures the probe rebuilds and reports size this frame.
setState(() {});
}
@override
Widget build(BuildContext context) {
// This ListView mimics your structure:
// - AnimatedContainer (with set _pageHeight)
// -> MeasurementWrapper
// -> PageView.builder -> TicketSelection
// - PageDots Indicator
return ListView(
padding: EdgeInsets.zero,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Dynamic-height PageView (selected/expanded change size)\n'
'Dots below let you jump between pages.',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 12),
// The viewport whose height we animate to match the current page.
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutCubic,
height: _pageHeight,
// Wrap with a measurement wrapper (optional here; mostly to show
// the structure you described).
child: MeasureSize(
onChange: (size) {
// This reports the actual viewport height (same as _pageHeight).
// We don't use it to compute height, but it's here to show the
// "MeasurementWrapper -> PageView" structure.
},
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _items.length,
itemBuilder: (context, index) {
return TicketSelectionCard(
data: _items[index],
selected: _selected[index],
expanded: _expanded[index],
onToggleSelected: () {
setState(() {
_selected[index] = !_selected[index];
});
_requestMeasure();
},
onToggleExpanded: () {
setState(() {
_expanded[index] = !_expanded[index];
});
_requestMeasure();
},
);
},
),
),
),
const SizedBox(height: 8),
Center(
child: PageDots(
count: _items.length,
current: _currentIndex,
onTap: (i) {
_pageController.animateToPage(
i,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
},
),
),
const SizedBox(height: 24),
// Hidden offstage "probe" that lays out the current page without
// the viewport height constraint, so we can measure its natural size.
Offstage(
offstage: true,
child: TickerMode(
enabled: false,
child: Padding(
padding: _pagePadding,
child: MeasureSize(
onChange: (size) {
// This is the natural height of the current page content.
final double minHeight = 120;
final double newHeight = size.height.clamp(minHeight, 2000);
if ((newHeight - _pageHeight).abs() > 0.5) {
setState(() {
_pageHeight = newHeight;
});
}
},
child: _ProbePage(
child: TicketSelectionCard(
data: _items[_currentIndex],
selected: _selected[_currentIndex],
expanded: _expanded[_currentIndex],
onToggleSelected: () {},
onToggleExpanded: () {},
),
),
),
),
),
),
// Extra filler below to show the ListView scrolls.
const SizedBox(height: 400),
],
);
}
}
// Ensures the probe is laid out with width constraints like the visible page,
// but with unconstrained height so the child gets its natural height.
class _ProbePage extends StatelessWidget {
const _ProbePage({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Constrain width to the available width; leave height unconstrained.
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth,
maxWidth: constraints.maxWidth,
),
child: child,
);
},
);
}
}
// A card whose height changes with selected + expanded states and dynamic text.
class TicketSelectionCard extends StatelessWidget {
const TicketSelectionCard({
super.key,
required this.data,
required this.selected,
required this.expanded,
required this.onToggleSelected,
required this.onToggleExpanded,
});
final TicketData data;
final bool selected;
final bool expanded;
final VoidCallback onToggleSelected;
final VoidCallback onToggleExpanded;
@override
Widget build(BuildContext context) {
final ColorScheme cs = Theme.of(context).colorScheme;
final Color bg =
selected ? cs.primaryContainer : cs.surfaceContainerHighest;
final Color fg = selected ? cs.onPrimaryContainer : cs.onSurface;
// Small visual differences per state to make heights different:
final double outerPaddingV = selected ? 16 : 12;
final double outerPaddingH = 16;
final double gap = 12;
return Card(
elevation: selected ? 2 : 0,
color: bg,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: selected ? cs.primary : cs.outlineVariant,
width: selected ? 1.4 : 1,
),
),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: outerPaddingV,
horizontal: outerPaddingH,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title + state chips
Row(
children: [
Expanded(
child: Text(
data.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: fg,
fontWeight: FontWeight.w600,
),
),
),
if (selected)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Selected',
style: TextStyle(
color: cs.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
SizedBox(height: gap),
// Base description. Collapsed shows 2 lines; expanded shows full.
AnimatedCrossFade(
firstChild: Text(
data.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fg.withOpacity(0.9)),
),
secondChild: Text(
data.description,
style: TextStyle(color: fg.withOpacity(0.9)),
),
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
// Extra selected info to create a larger selected+expanded combo.
if (selected) ...[
SizedBox(height: gap),
Row(
children: [
Icon(Icons.check_circle, color: cs.primary, size: 20),
const SizedBox(width: 8),
Text(
'Includes seat selection + priority boarding.',
style: TextStyle(color: fg.withOpacity(0.9)),
),
],
),
],
SizedBox(height: gap),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FilledButton.tonal(
onPressed: onToggleExpanded,
child: Text(expanded ? 'Collapse' : 'Expand'),
),
FilledButton(
onPressed: onToggleSelected,
child: Text(selected ? 'Unselect' : 'Select'),
),
],
),
],
),
),
);
}
}
class TicketData {
final String title;
final String description;
const TicketData({required this.title, required this.description});
}
// Simple page dots indicator
class PageDots extends StatelessWidget {
const PageDots({
super.key,
required this.count,
required this.current,
required this.onTap,
});
final int count;
final int current;
final void Function(int index) onTap;
@override
Widget build(BuildContext context) {
final ColorScheme cs = Theme.of(context).colorScheme;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final bool isActive = i == current;
return GestureDetector(
onTap: () => onTap(i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
width: isActive ? 20 : 10,
height: 10,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: isActive ? cs.primary : cs.outlineVariant,
borderRadius: BorderRadius.circular(999),
),
),
);
}),
);
}
}
// Measurement wrapper using a RenderBox to report child size after layout.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({
super.key,
required this.onChange,
required Widget child,
}) : super(child: child);
final void Function(Size size) onChange;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderMeasureSize(onChange);
}
@override
void updateRenderObject(
BuildContext context,
covariant _RenderMeasureSize renderObject,
) {
renderObject.onChange = onChange;
}
}
class _RenderMeasureSize extends RenderProxyBox {
_RenderMeasureSize(this.onChange);
void Function(Size size) onChange;
Size? _oldSize;
@override
void performLayout() {
super.performLayout();
final Size newSize = child?.size ?? Size.zero;
if (_oldSize == newSize) return;
_oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment