Created
October 20, 2025 20:38
-
-
Save hectorAguero/2af6e3329eac61f0b5818df4f5f3f407 to your computer and use it in GitHub Desktop.
main.dart
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 '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