diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/example/.metadata b/example/.metadata index 2d1be89..220a11f 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,8 +4,8 @@ # This file should be version controlled and should not be manually edited. version: - revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" - channel: "stable" + revision: "99d909aed0f862ecac957eb157353ab7d82da20b" + channel: "main" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - - platform: android - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - - platform: ios - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - - platform: linux - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - - platform: macos - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + create_revision: 99d909aed0f862ecac957eb157353ab7d82da20b + base_revision: 99d909aed0f862ecac957eb157353ab7d82da20b - platform: web - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - - platform: windows - create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + create_revision: 99d909aed0f862ecac957eb157353ab7d82da20b + base_revision: 99d909aed0f862ecac957eb157353ab7d82da20b # User provided section diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..e3f2b56 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + library_private_types_in_public_api: false \ No newline at end of file diff --git a/example/lib/avatars.dart b/example/lib/avatars.dart new file mode 100644 index 0000000..886b78e --- /dev/null +++ b/example/lib/avatars.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:overflow_view/overflow_view.dart'; + +class Avatar { + const Avatar(this.initials, this.color); + final String initials; + final Color color; +} + +String getInitials(int index) { + return String.fromCharCode(65 + (index % 26 + 1)); +} + +Color getColor(int index) { + return Colors.primaries[index % Colors.primaries.length].shade500; +} + +List generateAvatars(int count) { + return List.generate(count + 1, (index) => Avatar(getInitials(index), getColor(index))); +} + +class AvatarsDemo extends StatefulWidget { + const AvatarsDemo({super.key}); + + @override + State createState() => _AvatarsDemoState(); +} + +class _AvatarsDemoState extends State { + int _avatarCount = 5; + double _spacing = -40; + + @override + Widget build(BuildContext context) { + final avatars = generateAvatars(_avatarCount); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: OverflowView.flexible( + spacing: _spacing, + children: [ + for (int i = 0; i < avatars.length; i++) + AvatarWidget(text: avatars[i].initials, color: avatars[i].color) + ], + builder: (context, remaining) { + return AvatarWidget( + text: '+$remaining', + color: Colors.red, + ); + }, + ), + ), + ), + ), + Slider( + value: _avatarCount.toDouble(), + min: 1, + max: 50, + onChanged: (value) => setState(() => _avatarCount = value.toInt()), + ), + SizedBox(height: 20), + Text('Spacing (${_spacing.toInt()})'), + SizedBox(height: 10), + SizedBox( + width: 200, + child: Slider( + value: _spacing, + min: -40, + max: 40, + onChanged: (value) => setState( + () => _spacing = value, + ), + ), + ), + ], + ); + } +} + +class AvatarWidget extends StatelessWidget { + const AvatarWidget({ + super.key, + required this.text, + required this.color, + }); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: 40, + backgroundColor: color, + foregroundColor: Colors.white, + child: Text( + text, + style: TextStyle(fontSize: 30), + ), + ); + } +} diff --git a/example/lib/fixed_flexible.dart b/example/lib/fixed_flexible.dart new file mode 100644 index 0000000..40172ca --- /dev/null +++ b/example/lib/fixed_flexible.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:overflow_view/overflow_view.dart'; +import 'overflow_controls.dart'; + +class FixedVsFlexibleDemo extends StatefulWidget { + const FixedVsFlexibleDemo({super.key}); + + @override + State createState() => _FixedVsFlexibleDemoState(); +} + +class _FixedVsFlexibleDemoState extends State { + bool _fixed = false; + int _count = 10; + double _width = 300; + double _spacing = 8.0; + Axis _axis = Axis.horizontal; + MainAxisAlignment _mainAxisAlignment = MainAxisAlignment.start; + CrossAxisAlignment _crossAxisAlignment = CrossAxisAlignment.start; + + Widget _buildOverflow(BuildContext context, int count) { + return Container( + decoration: BoxDecoration( + color: Colors.yellow, + borderRadius: BorderRadius.circular(10), + ), + height: 50, + width: 50, + child: Center(child: Text("Ov: $count")), + ); + } + + List _buildChildren(BuildContext context, int count) { + return List.generate( + count, + (index) => Container( + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(10), + ), + height: index % 3 * 10 + 50, + width: 50 + index % 3 * 10, + child: Center(child: Text("Child: $index")), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(10), + ), + height: 300, + width: _width, + child: Center( + child: OverflowView( + mainAxisAlignment: _mainAxisAlignment, + crossAxisAlignment: _crossAxisAlignment, + builder: _buildOverflow, + direction: _axis, + layoutBehavior: _fixed ? OverflowViewLayoutBehavior.fixed : OverflowViewLayoutBehavior.flexible, + spacing: _spacing, + children: _buildChildren(context, _count), + ), + ), + ), + OverflowControls( + mainAxisAlignment: _mainAxisAlignment, + crossAxisAlignment: _crossAxisAlignment, + axis: _axis, + spacing: _spacing, + expandFirstChild: false, + fixed: _fixed, + count: _count, + width: _width, + onMainAxisAlignmentChanged: (value) { + setState(() { + _mainAxisAlignment = value; + }); + }, + onCrossAxisAlignmentChanged: (value) { + setState(() { + _crossAxisAlignment = value; + }); + }, + onAxisChanged: (value) { + setState(() { + _axis = value; + }); + }, + onSpacingChanged: (value) { + setState(() { + _spacing = value; + }); + }, + onFixedChanged: (value) { + setState(() { + _fixed = value; + }); + }, + onCountChanged: (value) { + setState(() { + _count = value; + }); + }, + onWidthChanged: (value) { + setState(() { + _width = value; + }); + }, + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 4e99f40..fee5287 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,329 +1,53 @@ +import 'package:example/avatars.dart'; +import 'package:example/fixed_flexible.dart'; +import 'package:example/menu.dart'; import 'package:flutter/material.dart'; -import 'package:overflow_view/overflow_view.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { + const MyApp({super.key}); + @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Overflow View Demo', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), - home: MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - MyHomePage({Key? key, required this.title}) : super(key: key); - - final String title; - - @override - _MyHomePageState createState() => _MyHomePageState(); -} - -class Avatar { - const Avatar(this.initials, this.color); - final String initials; - final Color color; -} - -const List avatars = [ - Avatar('AD', Colors.green), - Avatar('JG', Colors.pink), - Avatar('DA', Colors.blue), - Avatar('JA', Colors.black), - Avatar('CB', Colors.amber), - Avatar('RR', Colors.deepPurple), - Avatar('JD', Colors.pink), - Avatar('MB', Colors.amberAccent), - Avatar('AA', Colors.blueAccent), - Avatar('BA', Colors.tealAccent), - Avatar('CR', Colors.yellow), -]; - -class _MyHomePageState extends State { - int _counter = 1; - double ratio = 1; - - void _incrementCounter() { - setState(() { - _counter = (_counter + 1).clamp(0, avatars.length - 1); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'People', - style: TextStyle(fontSize: 20), - ), - SizedBox(height: 20), - OverflowView.flexible( - spacing: -40, - children: [ - for (int i = 0; i < _counter; i++) - AvatarWidget( - text: avatars[i].initials, - color: avatars[i].color, - ) + home: Scaffold( + appBar: AppBar( + title: Text('Overflow View Demo'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(32), + child: SingleChildScrollView( + child: Column( + children: [ + Text( + 'Avatars', + style: TextStyle(fontSize: 20), + ), + SizedBox(height: 20), + AvatarsDemo(), + Divider(), + Text('Menu Bar', style: TextStyle(fontSize: 20)), + SizedBox(height: 20), + MenuDemo(), + Divider(), + Text('Fixed vs Flexible', style: TextStyle(fontSize: 20)), + SizedBox(height: 20), + FixedVsFlexibleDemo(), ], - builder: (context, remaining) { - return AvatarWidget( - text: '+$remaining', - color: Colors.red, - ); - }, ), - SizedBox(height: 20), - FractionallySizedBox( - widthFactor: ratio, - child: CommandBar(), - ), - SizedBox(height: 20), - Expanded( - child: OverflowView( - direction: Axis.vertical, - spacing: 4, - children: [ - for (int i = 0; i < _counter; i++) - AvatarWidget( - text: avatars[i].initials, - color: avatars[i].color, - ) - ], - builder: (context, remaining) { - return SizedBox( - height: 80, - width: 80, - child: Stack( - fit: StackFit.expand, - children: [ - if (remaining > 0) - AvatarOverview( - position: 0, - remaining: remaining, - counter: _counter, - ), - if (remaining > 1) - AvatarOverview( - position: 1, - remaining: remaining, - counter: _counter, - ), - if (remaining > 2) - AvatarOverview( - position: 2, - remaining: remaining, - counter: _counter, - ), - if (remaining > 3) - AvatarOverview( - position: 3, - remaining: remaining, - counter: _counter, - ), - Positioned.fill( - child: Center( - child: FractionallySizedBox( - alignment: Alignment.center, - widthFactor: 0.5, - heightFactor: 0.5, - child: FittedBox( - child: AvatarWidget( - text: '+$remaining', - color: Colors.black.withOpacity(0.9), - ), - ), - ), - ), - ), - ], - ), - ); - }, - ), - ), - // Slider( - // value: ratio, - // min: 0, - // max: 1, - // divisions: 100, - // onChanged: (value) { - // setState(() { - // ratio = value; - // }); - // }, - // ), - SizedBox(height: 40), - ], + ), ), ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: Icon(Icons.add), - ), - ); - } -} - -class AvatarOverview extends StatelessWidget { - AvatarOverview({ - Key? key, - required int remaining, - required int position, - required int counter, - }) : index = counter - remaining + position, - alignment = _getAlignment(position), - super(key: key); - - final int index; - final Alignment alignment; - - @override - Widget build(BuildContext context) { - final Avatar avatar = avatars[index]; - return FractionallySizedBox( - key: ValueKey(index), - alignment: alignment, - widthFactor: 0.5, - heightFactor: 0.5, - child: FittedBox( - child: AvatarWidget( - text: avatar.initials, - color: avatar.color, - ), - ), - ); - } - - static Alignment _getAlignment(int position) { - switch (position) { - case 0: - return Alignment.topLeft; - case 1: - return Alignment.topRight; - case 2: - return Alignment.bottomLeft; - default: - return Alignment.bottomRight; - } - } -} - -class AvatarWidget extends StatelessWidget { - const AvatarWidget({ - Key? key, - required this.text, - required this.color, - }) : super(key: key); - - final String text; - final Color color; - - @override - Widget build(BuildContext context) { - return CircleAvatar( - radius: 40, - backgroundColor: color, - foregroundColor: Colors.white, - child: Text( - text, - style: TextStyle(fontSize: 30), - ), - ); - } -} - -class CommandBar extends StatelessWidget { - const CommandBar({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final List commands = [ - MenuItemData(id: 'a', label: 'File'), - MenuItemData(id: 'b', icon: Icons.save, label: 'Save'), - MenuItemData(id: 'c', label: 'Edit'), - MenuItemData(id: 'd', label: 'View'), - MenuItemData(id: 'e', icon: Icons.exit_to_app), - MenuItemData(id: 'f', label: 'Long Command'), - MenuItemData(id: 'f', label: 'Very Long Command'), - MenuItemData(id: 'f', label: 'Very very Long Command'), - MenuItemData(id: 'f', label: 'Help'), - ]; - - return OverflowView.flexible( - spacing: -4, - children: [...commands.map((e) => _MenuItem(data: e))], - builder: (context, remaining) { - return PopupMenuButton( - icon: Icon(Icons.menu), - itemBuilder: (context) { - return commands - .skip(commands.length - remaining) - .map((e) => PopupMenuItem( - value: e.id, - child: _MenuItem(data: e), - )) - .toList(); - }, - ); - }, - ); - } -} - -class MenuItemData { - const MenuItemData({ - required this.id, - this.label, - this.icon, - }); - - final String id; - final String? label; - final IconData? icon; -} - -class _MenuItem extends StatelessWidget { - const _MenuItem({ - Key? key, - required this.data, - }) : super(key: key); - - final MenuItemData data; - - @override - Widget build(BuildContext context) { - return TextButton( - onPressed: () {}, - child: Row( - children: [ - if (data.icon != null) Icon(data.icon), - if (data.icon != null && data.label != null) SizedBox(width: 8), - if (data.label != null) Text(data.label!), - ], - ), ); } } diff --git a/example/lib/menu.dart b/example/lib/menu.dart new file mode 100644 index 0000000..b3911fc --- /dev/null +++ b/example/lib/menu.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:overflow_view/overflow_view.dart'; +import 'overflow_controls.dart'; + +class MenuDemo extends StatefulWidget { + const MenuDemo({super.key}); + + @override + _MenuDemoState createState() => _MenuDemoState(); +} + +class _MenuDemoState extends State { + MainAxisAlignment _mainAxisAlignment = MainAxisAlignment.start; + CrossAxisAlignment _crossAxisAlignment = CrossAxisAlignment.center; + bool _expandFirstChild = true; + double _spacing = 4; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: MenuBar( + mainAxisAlignment: _mainAxisAlignment, + crossAxisAlignment: _crossAxisAlignment, + spacing: _spacing, + expandFirstChild: _expandFirstChild, + ), + ), + ), + ), + SizedBox(height: 20), + OverflowControls( + mainAxisAlignment: _mainAxisAlignment, + crossAxisAlignment: _crossAxisAlignment, + axis: Axis.horizontal, + spacing: _spacing, + expandFirstChild: _expandFirstChild, + fixed: false, + count: 0, + width: 0, + onMainAxisAlignmentChanged: (value) { + setState(() { + _mainAxisAlignment = value; + }); + }, + onCrossAxisAlignmentChanged: (value) { + setState(() { + _crossAxisAlignment = value; + }); + }, + onSpacingChanged: (value) { + setState(() { + _spacing = value; + }); + }, + onExpandFirstChildChanged: (value) { + setState(() { + _expandFirstChild = value; + }); + }, + ), + ], + ); + } +} + +class MenuBar extends StatelessWidget { + final MainAxisAlignment mainAxisAlignment; + final CrossAxisAlignment crossAxisAlignment; + final bool expandFirstChild; + final double spacing; + + const MenuBar({ + super.key, + this.mainAxisAlignment = MainAxisAlignment.start, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.expandFirstChild = false, + this.spacing = 4, + }); + + @override + Widget build(BuildContext context) { + final List commands = [ + MenuItemData(id: 'a', label: 'File'), + MenuItemData(id: 'b', icon: Icons.save, label: 'Save'), + MenuItemData(id: 'c', label: 'Edit'), + MenuItemData(id: 'd', label: 'View'), + MenuItemData(id: 'e', icon: Icons.exit_to_app), + MenuItemData(id: 'f', label: 'Long Command'), + MenuItemData(id: 'f', label: 'Very Long Command'), + MenuItemData(id: 'f', label: 'Very very Long Command'), + MenuItemData(id: 'f', label: 'Help'), + ]; + + return OverflowView( + spacing: spacing, + layoutBehavior: + expandFirstChild ? OverflowViewLayoutBehavior.expandFirstFlexible : OverflowViewLayoutBehavior.flexible, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Menu title', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ...commands.map((e) => _MenuItem(data: e)) + ], + builder: (context, remaining) { + return PopupMenuButton( + icon: Icon(Icons.menu), + itemBuilder: (context) { + return commands + .skip(commands.length - remaining) + .map((e) => PopupMenuItem( + value: e.id, + child: _MenuItem(data: e), + )) + .toList(); + }, + ); + }, + ); + } +} + +class MenuItemData { + const MenuItemData({ + required this.id, + this.label, + this.icon, + }); + + final String id; + final String? label; + final IconData? icon; +} + +class _MenuItem extends StatelessWidget { + const _MenuItem({ + required this.data, + }); + + final MenuItemData data; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () {}, + child: Row( + children: [ + if (data.icon != null) Icon(data.icon), + if (data.icon != null && data.label != null) SizedBox(width: 8), + if (data.label != null) Text(data.label!), + ], + ), + ); + } +} diff --git a/example/lib/overflow_controls.dart b/example/lib/overflow_controls.dart new file mode 100644 index 0000000..f5ead84 --- /dev/null +++ b/example/lib/overflow_controls.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +class OverflowControls extends StatelessWidget { + final MainAxisAlignment mainAxisAlignment; + final CrossAxisAlignment crossAxisAlignment; + final Axis axis; + final double spacing; + final bool expandFirstChild; + final bool fixed; + final int count; + final double width; + final ValueChanged? onMainAxisAlignmentChanged; + final ValueChanged? onCrossAxisAlignmentChanged; + final ValueChanged? onAxisChanged; + final ValueChanged? onSpacingChanged; + final ValueChanged? onExpandFirstChildChanged; + final ValueChanged? onFixedChanged; + final ValueChanged? onCountChanged; + final ValueChanged? onWidthChanged; + + const OverflowControls({ + super.key, + required this.mainAxisAlignment, + required this.crossAxisAlignment, + required this.axis, + required this.spacing, + required this.expandFirstChild, + required this.fixed, + required this.count, + required this.width, + this.onMainAxisAlignmentChanged, + this.onCrossAxisAlignmentChanged, + this.onAxisChanged, + this.onSpacingChanged, + this.onExpandFirstChildChanged, + this.onFixedChanged, + this.onCountChanged, + this.onWidthChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (onCountChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Count: $count"), + SizedBox(width: 8), + SizedBox( + width: 150, + child: Slider( + value: count.toDouble(), + max: 20, + min: 1, + onChanged: (value) { + onCountChanged?.call(value.toInt()); + }, + ), + ), + ], + ), + if (onWidthChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Width: ${width.toInt()}"), + SizedBox(width: 8), + SizedBox( + width: 150, + child: Slider( + value: width, + max: 500, + min: 100, + onChanged: onWidthChanged, + ), + ), + ], + ), + if (onSpacingChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Spacing: ${spacing.toInt()}"), + SizedBox(width: 8), + SizedBox( + width: 150, + child: Slider( + value: spacing, + max: 50, + min: -10, + onChanged: onSpacingChanged, + ), + ), + ], + ), + ], + ), + if (onExpandFirstChildChanged != null || onFixedChanged != null) + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (onExpandFirstChildChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: expandFirstChild, + onChanged: onExpandFirstChildChanged, + ), + Text('Expand first child'), + ], + ), + if (onFixedChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Fixed"), + Switch( + value: fixed, + onChanged: onFixedChanged, + ), + ], + ), + ], + ), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (onAxisChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Axis:"), + SizedBox(width: 8), + DropdownButton( + value: axis, + onChanged: (value) { + if (value != null) { + onAxisChanged?.call(value); + } + }, + items: Axis.values.map((axis) { + return DropdownMenuItem( + value: axis, + child: Text(axis == Axis.horizontal ? "Horizontal" : "Vertical"), + ); + }).toList(), + ), + ], + ), + if (onMainAxisAlignmentChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("MainAxisAlignment:"), + SizedBox(width: 8), + DropdownButton( + value: mainAxisAlignment, + onChanged: (value) { + if (value != null) { + onMainAxisAlignmentChanged?.call(value); + } + }, + items: MainAxisAlignment.values.map((alignment) { + return DropdownMenuItem( + value: alignment, + child: Text(alignment.toString().split('.').last), + ); + }).toList(), + ), + ], + ), + if (onCrossAxisAlignmentChanged != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("CrossAxisAlignment:"), + SizedBox(width: 8), + DropdownButton( + value: crossAxisAlignment, + onChanged: (value) { + if (value != null) { + onCrossAxisAlignmentChanged?.call(value); + } + }, + items: CrossAxisAlignment.values.map((alignment) { + return DropdownMenuItem( + value: alignment, + child: Text(alignment.toString().split('.').last), + ); + }).toList(), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index e6d4f72..6aeb20b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -54,6 +54,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -63,26 +71,34 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" matcher: dependency: transitive description: @@ -171,10 +187,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" value_layout_builder: dependency: transitive description: @@ -187,10 +203,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -200,5 +216,5 @@ packages: source: hosted version: "15.0.0" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.32.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ea69f0a..95ab8da 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^6.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..29b5808 --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/lib/overflow_view.dart b/lib/overflow_view.dart index 5e3fc58..d75d570 100644 --- a/lib/overflow_view.dart +++ b/lib/overflow_view.dart @@ -1,3 +1,4 @@ library overflow_view; export 'src/widgets/overflow_view.dart'; +export 'src/rendering/overflow_view.dart' show OverflowViewLayoutBehavior; diff --git a/lib/src/rendering/overflow_view.dart b/lib/src/rendering/overflow_view.dart index 9d45af0..dd8a55e 100644 --- a/lib/src/rendering/overflow_view.dart +++ b/lib/src/rendering/overflow_view.dart @@ -1,37 +1,67 @@ +import 'dart:math' as math; + import 'package:flutter/rendering.dart'; import 'package:value_layout_builder/value_layout_builder.dart'; -import 'dart:math' as math; +enum OverflowViewLayoutBehavior { + fixed, + flexible, + expandFirstFlexible, +} /// Parent data for use with [RenderOverflowView]. class OverflowViewParentData extends ContainerBoxParentData { bool? offstage; } -enum OverflowViewLayoutBehavior { - fixed, - flexible, -} - class RenderOverflowView extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { + MainAxisAlignment _mainAxisAlignment; + CrossAxisAlignment _crossAxisAlignment; + Axis _direction; + double _spacing; + OverflowViewLayoutBehavior _layoutBehavior; + + bool _isHorizontal; + + bool _hasOverflow = false; RenderOverflowView({ List? children, required Axis direction, required double spacing, required OverflowViewLayoutBehavior layoutBehavior, - }) : assert(spacing > double.negativeInfinity && spacing < double.infinity), + required MainAxisAlignment mainAxisAlignment, + required CrossAxisAlignment crossAxisAlignment, + }) : assert( + mainAxisAlignment != MainAxisAlignment.spaceBetween && + mainAxisAlignment != MainAxisAlignment.spaceAround && + mainAxisAlignment != MainAxisAlignment.spaceEvenly, + "mainAxisAlignment must not be spaceBetween, spaceAround or spaceEvenly (current not supported)", + ), + assert( + crossAxisAlignment != CrossAxisAlignment.baseline && crossAxisAlignment != CrossAxisAlignment.stretch, + "crossAxisAlignment must not be baseline or stretch (current not supported)", + ), _direction = direction, _spacing = spacing, + _mainAxisAlignment = mainAxisAlignment, _layoutBehavior = layoutBehavior, + _crossAxisAlignment = crossAxisAlignment, _isHorizontal = direction == Axis.horizontal { addAll(children); } + CrossAxisAlignment get crossAxisAlignment => _crossAxisAlignment; + + set crossAxisAlignment(CrossAxisAlignment value) { + if (_crossAxisAlignment != value) { + _crossAxisAlignment = value; + markNeedsLayout(); + } + } Axis get direction => _direction; - Axis _direction; set direction(Axis value) { if (_direction != value) { _direction = value; @@ -40,8 +70,24 @@ class RenderOverflowView extends RenderBox } } + OverflowViewLayoutBehavior get layoutBehavior => _layoutBehavior; + + set layoutBehavior(OverflowViewLayoutBehavior value) { + if (_layoutBehavior != value) { + _layoutBehavior = value; + markNeedsLayout(); + } + } + + MainAxisAlignment get mainAxisAlignment => _mainAxisAlignment; + set mainAxisAlignment(MainAxisAlignment value) { + if (_mainAxisAlignment != value) { + _mainAxisAlignment = value; + markNeedsLayout(); + } + } + double get spacing => _spacing; - double _spacing; set spacing(double value) { assert(value > double.negativeInfinity && value < double.infinity); if (_spacing != value) { @@ -50,41 +96,181 @@ class RenderOverflowView extends RenderBox } } - OverflowViewLayoutBehavior get layoutBehavior => _layoutBehavior; - OverflowViewLayoutBehavior _layoutBehavior; - set layoutBehavior(OverflowViewLayoutBehavior value) { - if (_layoutBehavior != value) { - _layoutBehavior = value; - markNeedsLayout(); - } + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + // The x, y parameters have the top left of the node's box as the origin. + visitOnlyOnStageChildren((renderObject) { + final RenderBox child = renderObject as RenderBox; + final OverflowViewParentData childParentData = child.parentData as OverflowViewParentData; + result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + }); + + return false; } - bool _isHorizontal; @override - void setupParentData(RenderBox child) { - if (child.parentData is! OverflowViewParentData) - child.parentData = OverflowViewParentData(); - } + void paint(PaintingContext context, Offset offset) { + void paintChild(RenderObject child) { + final OverflowViewParentData childParentData = child.parentData as OverflowViewParentData; + if (childParentData.offstage == false) { + context.paintChild(child, childParentData.offset + offset); + } else { + // We paint it outside the box. + context.paintChild(child, size.bottomRight(Offset.zero)); + } + } - double _getCrossSize(RenderBox child) { - switch (_direction) { - case Axis.horizontal: - return child.size.height; - case Axis.vertical: - return child.size.width; + void defaultPaint(PaintingContext context, Offset offset) { + visitOnlyOnStageChildren(paintChild); } - } - double _getMainSize(RenderBox child) { - switch (_direction) { - case Axis.horizontal: - return child.size.width; - case Axis.vertical: - return child.size.height; + if (_hasOverflow) { + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + defaultPaint, + clipBehavior: Clip.hardEdge, + ); + } else { + defaultPaint(context, offset); } } - bool _hasOverflow = false; + void _performFixedLayout() { + double availableExtent = _isHorizontal ? constraints.maxWidth : constraints.maxHeight; + + // First we retrieve the size of all the children. + final children = _layoutChildrenSizes(); + + // Needed to calculate the cross axis alignment later + double maxCrossSize = 0; + + double maxMainSize = 0; + + // Calculate the total size needed for all children (excluding overflow indicator) + + for (final child in children) { + double childCrossSize = _getCrossSize(child); + maxCrossSize = math.max(maxCrossSize, childCrossSize); + maxMainSize = math.max(maxMainSize, _getMainSize(child)); + } + + // Add spacing between children + + // Determine how many children can fit + int fittingChildren = 0; + double filledExtent = 0; + bool showOverflowIndicator = false; + + for (final child in children) { + // Check if this child would fit + if (filledExtent + maxMainSize + (fittingChildren > 0 ? spacing : 0) <= availableExtent) { + final childParentData = child.parentData as OverflowViewParentData; + childParentData.offstage = false; + filledExtent += maxMainSize + (fittingChildren > 0 ? spacing : 0); + fittingChildren++; + } else { + showOverflowIndicator = true; + final childParentData = child.parentData as OverflowViewParentData; + childParentData.offstage = true; + } + } + + final renderedChildren = children.where((child) => child.isOnstage).toList(); + + if (showOverflowIndicator) { + // We need to place the overflow indicator. + final RenderBox overflowIndicator = lastChild!; + BoxValueConstraints overflowIndicatorConstraints = BoxValueConstraints( + value: childCount - fittingChildren - 1, + constraints: _childConstraints, + ); + + overflowIndicator.layout( + overflowIndicatorConstraints, + parentUsesSize: true, + ); + + final OverflowViewParentData overflowIndicatorParentData = overflowIndicator.parentData as OverflowViewParentData; + overflowIndicatorParentData.offstage = false; + + double indicatorSize = _getMainSize(overflowIndicator); + filledExtent += indicatorSize; + + // Remove children until we can fit the overflow indicator + while (filledExtent + (fittingChildren > 0 ? spacing : 0) > availableExtent) { + final RenderBox lastChild = renderedChildren.last; + final OverflowViewParentData lastChildParentData = lastChild.parentData as OverflowViewParentData; + lastChildParentData.offstage = true; + + renderedChildren.removeLast(); + fittingChildren--; + final freed = _getMainSize(lastChild); + filledExtent -= freed + (fittingChildren > 0 ? spacing : 0); + } + + // Now that we know the final count of fitting children we + // layout again to pass the correct count to the overflow indicator. + overflowIndicatorConstraints = BoxValueConstraints( + value: childCount - fittingChildren - 1, + constraints: _childConstraints, + ); + + overflowIndicator.layout( + overflowIndicatorConstraints, + parentUsesSize: true, + ); + + renderedChildren.add(overflowIndicator); + maxCrossSize = math.max(maxCrossSize, _getCrossSize(overflowIndicator)); + } + + // Calculate alignment offset + final double alignmentOffset = _calculateMainAxisAlignmentOffset(filledExtent, availableExtent); + + // Position all rendered children with uniform spacing + double offset = alignmentOffset; + for (final child in renderedChildren) { + final childParentData = child.parentData as OverflowViewParentData; + final double childCrossSize = _getCrossSize(child); + + double childCrossOffset = 0; + if (crossAxisAlignment == CrossAxisAlignment.start) { + childCrossOffset = 0; + } else if (crossAxisAlignment == CrossAxisAlignment.end) { + childCrossOffset = maxCrossSize - childCrossSize; + } else if (crossAxisAlignment == CrossAxisAlignment.center) { + childCrossOffset = (maxCrossSize - childCrossSize) / 2; + } + + if (_isHorizontal) { + childParentData.offset = Offset(offset, childCrossOffset); + } else { + childParentData.offset = Offset(childCrossOffset, offset); + } + + offset += maxMainSize + spacing; + } + + Size idealSize; + + double trailingSpace = availableExtent - filledExtent; + if (_isHorizontal) { + idealSize = Size(offset - spacing + trailingSpace, maxCrossSize); + } else { + idealSize = Size(maxCrossSize, offset - spacing + trailingSpace); + } + + size = constraints.constrain(idealSize); + } @override void performLayout() { @@ -92,318 +278,258 @@ class RenderOverflowView extends RenderBox assert(firstChild != null); resetOffstage(); if (layoutBehavior == OverflowViewLayoutBehavior.fixed) { - performFixedLayout(); + _performFixedLayout(); } else { - performFlexibleLayout(); + _performFlexibleLayout(); } } void resetOffstage() { visitChildren((child) { - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; + final OverflowViewParentData childParentData = child.parentData as OverflowViewParentData; childParentData.offstage = null; }); } - void performFixedLayout() { - RenderBox child = firstChild!; - final BoxConstraints childConstraints = constraints.loosen(); - final double maxExtent = - _isHorizontal ? constraints.maxWidth : constraints.maxHeight; - - OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - child.layout(childConstraints, parentUsesSize: true); - final double childExtent = child.size.getMainExtent(direction); - final double crossExtent = child.size.getCrossExtent(direction); - final BoxConstraints otherChildConstraints = _isHorizontal - ? childConstraints.tighten(width: childExtent, height: crossExtent) - : childConstraints.tighten(height: childExtent, width: crossExtent); - - final double childStride = childExtent + spacing; - Offset getChildOffset(int index) { - final double mainAxisOffset = index * childStride; - final double crossAxisOffset = 0; - if (_isHorizontal) { - return Offset(mainAxisOffset, crossAxisOffset); - } else { - return Offset(crossAxisOffset, mainAxisOffset); + @override + void setupParentData(RenderBox child) { + if (child.parentData is! OverflowViewParentData) child.parentData = OverflowViewParentData(); + } + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitOnlyOnStageChildren(visitor); + } + + void visitOnlyOnStageChildren(RenderObjectVisitor visitor) { + visitChildren((child) { + if (child.isOnstage) { + visitor(child); } - } + }); + } - int onstageCount = 0; - final int count = childCount - 1; - final double requestedExtent = - childExtent * (childCount - 1) + spacing * (childCount - 2); - final int renderedChildCount = requestedExtent <= maxExtent - ? count - : (maxExtent + spacing) ~/ childStride - 1; - final int unRenderedChildCount = count - renderedChildCount; - if (renderedChildCount > 0) { - childParentData.offstage = false; - onstageCount++; - } + List _layoutChildrenSizes() { + RenderBox child = firstChild!; - for (int i = 1; i < renderedChildCount; i++) { - child = childParentData.nextSibling!; - childParentData = child.parentData as OverflowViewParentData; - child.layout(otherChildConstraints); - childParentData.offset = getChildOffset(i); - childParentData.offstage = false; - onstageCount++; - } + final List children = []; while (child != lastChild) { + final childParentData = child.parentData as OverflowViewParentData; + child.layout(_childConstraints, parentUsesSize: true); + children.add(child); child = childParentData.nextSibling!; - childParentData = child.parentData as OverflowViewParentData; - childParentData.offstage = true; } - if (unRenderedChildCount > 0) { - // We have to layout the overflow indicator. - final RenderBox overflowIndicator = lastChild!; + return children; + } - final BoxValueConstraints overflowIndicatorConstraints = - BoxValueConstraints( - value: unRenderedChildCount, - constraints: otherChildConstraints, - ); - overflowIndicator.layout(overflowIndicatorConstraints); - final OverflowViewParentData overflowIndicatorParentData = - overflowIndicator.parentData as OverflowViewParentData; - overflowIndicatorParentData.offset = getChildOffset(renderedChildCount); - overflowIndicatorParentData.offstage = false; - onstageCount++; + double _calculateMainAxisAlignmentOffset(double totalChildrenSize, double availableSize) { + switch (mainAxisAlignment) { + case MainAxisAlignment.start: + return 0.0; + case MainAxisAlignment.end: + return availableSize - totalChildrenSize; + case MainAxisAlignment.center: + return (availableSize - totalChildrenSize) / 2.0; + default: + return 0.0; } + } - final double mainAxisExtent = onstageCount * childStride - spacing; - final requestedSize = _isHorizontal - ? Size(mainAxisExtent, crossExtent) - : Size(crossExtent, mainAxisExtent); + BoxConstraints get _childConstraints { + final double maxCrossExtent = _isHorizontal ? constraints.maxHeight : constraints.maxWidth; + return _isHorizontal + ? BoxConstraints.loose(Size(double.infinity, maxCrossExtent)) + : BoxConstraints.loose(Size(maxCrossExtent, double.infinity)); + } - size = constraints.constrain(requestedSize); + double _getCrossSize(RenderBox child) { + switch (_direction) { + case Axis.horizontal: + return child.size.height; + case Axis.vertical: + return child.size.width; + } } - void performFlexibleLayout() { - RenderBox child = firstChild!; - List renderBoxes = []; - int unRenderedChildCount = childCount - 1; - double availableExtent = - _isHorizontal ? constraints.maxWidth : constraints.maxHeight; - double offset = 0; - final double maxCrossExtent = - _isHorizontal ? constraints.maxHeight : constraints.maxWidth; + double _getMainSize(RenderBox child) { + switch (_direction) { + case Axis.horizontal: + return child.size.width; + case Axis.vertical: + return child.size.height; + } + } - final BoxConstraints childConstraints = _isHorizontal - ? BoxConstraints.loose(Size(double.infinity, maxCrossExtent)) - : BoxConstraints.loose(Size(maxCrossExtent, double.infinity)); + void _performFlexibleLayout() { + double availableExtent = _isHorizontal ? constraints.maxWidth : constraints.maxHeight; bool showOverflowIndicator = false; - while (child != lastChild) { - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - child.layout(childConstraints, parentUsesSize: true); + // |_______| availableExtent + // □ □ □ □ □ □ + // ^overflowing child + // <-------> fitting children (count) + + // First we retrieve the size of all the children. + final children = _layoutChildrenSizes(); - final double childMainSize = _getMainSize(child); + // Needed to calculate the cross axis alignment later + double maxCrossSize = 0; - if (childMainSize <= availableExtent) { - // We have room to paint this child. - renderBoxes.add(child); + // Keep track of the total size of the children that are already on stage + double filledExtent = 0; + + int fittingChildren = 0; + + for (final child in children) { + double childMainSize = _getMainSize(child); + double childCrossSize = _getCrossSize(child); + maxCrossSize = math.max(maxCrossSize, childCrossSize); + + final childParentData = child.parentData as OverflowViewParentData; + + // Check if the filled space is less than the available extent. + if (filledExtent + childMainSize + _spacingExtent(fittingChildren) <= availableExtent) { childParentData.offstage = false; - childParentData.offset = - _isHorizontal ? Offset(offset, 0) : Offset(0, offset); - - final double childStride = spacing + childMainSize; - offset += childStride; - availableExtent -= childStride; - unRenderedChildCount--; - child = childParentData.nextSibling!; + filledExtent += childMainSize; + fittingChildren++; } else { - // We have no room to paint any further child. showOverflowIndicator = true; + childParentData.offstage = true; break; } } + final renderedChildren = children.where((child) => child.isOnstage).toList(); + if (showOverflowIndicator) { - // We didn't layout all the children. + // We need to place the overflow indicator. + // We start by determining its size, by passing the value of already + // overflowing children. final RenderBox overflowIndicator = lastChild!; - final BoxValueConstraints overflowIndicatorConstraints = - BoxValueConstraints( - value: unRenderedChildCount, - constraints: childConstraints, + BoxValueConstraints overflowIndicatorConstraints = BoxValueConstraints( + value: childCount - fittingChildren - 1, + constraints: _childConstraints, ); + overflowIndicator.layout( overflowIndicatorConstraints, parentUsesSize: true, ); - final double childMainSize = _getMainSize(overflowIndicator); + final OverflowViewParentData overflowIndicatorParentData = overflowIndicator.parentData as OverflowViewParentData; + overflowIndicatorParentData.offstage = false; - // We need to remove the children that prevent the overflowIndicator - // to paint. - while (childMainSize > availableExtent && renderBoxes.isNotEmpty) { - final RenderBox child = renderBoxes.removeLast(); - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - childParentData.offstage = true; - final double childStride = _getMainSize(child) + spacing; + double indicatorSize = _getMainSize(overflowIndicator); + filledExtent += indicatorSize; - availableExtent += childStride; - unRenderedChildCount++; - offset -= childStride; - } + // Remove children until we can fit the overflow indicator fits. - if (childMainSize > availableExtent) { - // We cannot paint any child because there is not enough space. - _hasOverflow = true; - } + while (filledExtent + _spacingExtent(fittingChildren + 1) > availableExtent) { + final RenderBox lastChild = renderedChildren.last; + + final OverflowViewParentData lastChildParentData = lastChild.parentData as OverflowViewParentData; + lastChildParentData.offstage = true; + + renderedChildren.removeLast(); + fittingChildren--; + final freed = _getMainSize(lastChild); - if (overflowIndicatorConstraints.value != unRenderedChildCount) { - // The number of unrendered child changed, we have to layout the - // indicator another time. - overflowIndicator.layout( - BoxValueConstraints( - value: unRenderedChildCount, - constraints: childConstraints, - ), - parentUsesSize: true, - ); + filledExtent -= freed; } - renderBoxes.add(overflowIndicator); + // Now that we know the final count of fitting children we + // layout again to pass the correct count to the overflow indicator. - final OverflowViewParentData overflowIndicatorParentData = - overflowIndicator.parentData as OverflowViewParentData; - overflowIndicatorParentData.offset = - _isHorizontal ? Offset(offset, 0) : Offset(0, offset); - overflowIndicatorParentData.offstage = false; - offset += childMainSize; - } else { - // We layout all children. We need to adjust the offset used to compute - // the final size. - offset -= spacing; - - // We need to layout the overflowIndicator because we may have already - // laid it out with parentUsesSize: true before. - // When unmounting a _LayoutBuilderElement, it calls markNeedsLayout - // a last time, and can cause error. - lastChild?.layout(BoxValueConstraints( - value: unRenderedChildCount, - constraints: childConstraints, - )); - - // Because the overflow indicator will be paint outside of the screen, - // we need to say that there is an overflow. - _hasOverflow = true; + overflowIndicatorConstraints = BoxValueConstraints( + value: childCount - fittingChildren - 1, + constraints: _childConstraints, + ); + + overflowIndicator.layout( + overflowIndicatorConstraints, + parentUsesSize: true, + ); + + renderedChildren.add(overflowIndicator); + + maxCrossSize = math.max(maxCrossSize, _getCrossSize(overflowIndicator)); } - final double crossSize = renderBoxes.fold( - 0, - (previousValue, element) => math.max( - previousValue, - _getCrossSize(element), - ), - ); - - // By default we center all children in the cross-axis. - for (final child in renderBoxes) { - final double childCrossPosition = - crossSize / 2.0 - _getCrossSize(child) / 2.0; - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - childParentData.offset = _isHorizontal - ? Offset(childParentData.offset.dx, childCrossPosition) - : Offset(childCrossPosition, childParentData.offset.dy); + double remainder = availableExtent - filledExtent - _spacingExtent(renderedChildren.length); + + // We increase the size of the first child to fill the leading space. + // we consume the remainder space. + if (layoutBehavior == OverflowViewLayoutBehavior.expandFirstFlexible) { + double childMainSize = _getMainSize(children.first); + + firstChild!.layout( + BoxConstraints.tight(Size(childMainSize + remainder, maxCrossSize)), + parentUsesSize: true, + ); + + remainder = 0; } - Size idealSize; - if (_isHorizontal) { - idealSize = Size(offset, crossSize); - } else { - idealSize = Size(crossSize, offset); + // We fill the extent based on the offset + double offset = 0; + + // If we try to center the children we start with half the remaining space. + if (mainAxisAlignment == MainAxisAlignment.center) { + offset = remainder / 2; } - size = constraints.constrain(idealSize); - } + // If we try to align the children at the end we start with the remaining space. + if (mainAxisAlignment == MainAxisAlignment.end) { + offset = remainder; + } - void visitOnlyOnStageChildren(RenderObjectVisitor visitor) { - visitChildren((child) { - if (child.isOnstage) { - visitor(child); - } - }); - } + for (final child in renderedChildren) { + final childParentData = child.parentData as OverflowViewParentData; - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - visitOnlyOnStageChildren(visitor); - } + final double childCrossSize = _getCrossSize(child); - @override - void paint(PaintingContext context, Offset offset) { - void paintChild(RenderObject child) { - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - if (childParentData.offstage == false) { - context.paintChild(child, childParentData.offset + offset); + double childCrossOffset = 0; + + if (crossAxisAlignment == CrossAxisAlignment.start) { + childCrossOffset = 0; + } else if (crossAxisAlignment == CrossAxisAlignment.end) { + childCrossOffset = maxCrossSize - childCrossSize; + } else if (crossAxisAlignment == CrossAxisAlignment.center) { + childCrossOffset = (maxCrossSize - childCrossSize) / 2; + } + + if (_isHorizontal) { + childParentData.offset = Offset(offset, childCrossOffset); } else { - // We paint it outside the box. - context.paintChild(child, size.bottomRight(Offset.zero)); + childParentData.offset = Offset(childCrossOffset, offset); } - } - void defaultPaint(PaintingContext context, Offset offset) { - visitOnlyOnStageChildren(paintChild); + offset += _getMainSize(child) + spacing; } - if (_hasOverflow) { - context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - defaultPaint, - clipBehavior: Clip.hardEdge, - ); + final trailingSpace = availableExtent - filledExtent; + + Size idealSize; + if (_isHorizontal) { + idealSize = Size(offset + trailingSpace, maxCrossSize); } else { - defaultPaint(context, offset); + idealSize = Size(maxCrossSize, offset + trailingSpace); } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - // The x, y parameters have the top left of the node's box as the origin. - visitOnlyOnStageChildren((renderObject) { - final RenderBox child = renderObject as RenderBox; - final OverflowViewParentData childParentData = - child.parentData as OverflowViewParentData; - result.addWithPaintOffset( - offset: childParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - childParentData.offset); - return child.hitTest(result, position: transformed); - }, - ); - }); - return false; - } -} - -extension on Size { - double getMainExtent(Axis axis) { - return axis == Axis.horizontal ? width : height; + size = constraints.constrain(idealSize); } - double getCrossExtent(Axis axis) { - return axis == Axis.horizontal ? height : width; + double _spacingExtent(int childCount) { + if (childCount == 0) { + return 0; + } + return spacing * (childCount - 1); } } extension RenderObjectExtensions on RenderObject { - bool get isOnstage => - (parentData as OverflowViewParentData).offstage == false; + bool get isOnstage => (parentData as OverflowViewParentData).offstage == false; } diff --git a/lib/src/widgets/overflow_view.dart b/lib/src/widgets/overflow_view.dart index 4136745..9db09ff 100644 --- a/lib/src/widgets/overflow_view.dart +++ b/lib/src/widgets/overflow_view.dart @@ -23,16 +23,21 @@ class OverflowView extends MultiChildRenderObjectWidget { OverflowView({ Key? key, required OverflowIndicatorBuilder builder, + MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start, + CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center, Axis direction = Axis.horizontal, required List children, + OverflowViewLayoutBehavior layoutBehavior = OverflowViewLayoutBehavior.fixed, double spacing = 0, }) : this._all( key: key, builder: builder, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, direction: direction, children: children, spacing: spacing, - layoutBehavior: OverflowViewLayoutBehavior.fixed, + layoutBehavior: layoutBehavior, ); /// Creates a flexible [OverflowView]. @@ -44,29 +49,33 @@ class OverflowView extends MultiChildRenderObjectWidget { Key? key, required OverflowIndicatorBuilder builder, Axis direction = Axis.horizontal, + MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start, + CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center, required List children, double spacing = 0, }) : this._all( key: key, builder: builder, direction: direction, + mainAxisAlignment: mainAxisAlignment, children: children, spacing: spacing, + crossAxisAlignment: crossAxisAlignment, layoutBehavior: OverflowViewLayoutBehavior.flexible, ); OverflowView._all({ - Key? key, + super.key, required OverflowIndicatorBuilder builder, + this.mainAxisAlignment = MainAxisAlignment.start, + this.crossAxisAlignment = CrossAxisAlignment.center, this.direction = Axis.horizontal, required List children, this.spacing = 0, required OverflowViewLayoutBehavior layoutBehavior, - }) : assert(spacing > double.negativeInfinity && - spacing < double.infinity), + }) : assert(spacing > double.negativeInfinity && spacing < double.infinity), _layoutBehavior = layoutBehavior, super( - key: key, children: [ ...children, ValueLayoutBuilder( @@ -88,7 +97,20 @@ class OverflowView extends MultiChildRenderObjectWidget { final OverflowViewLayoutBehavior _layoutBehavior; + /// The alignment of the children along the main axis. + /// + /// For example, if [mainAxisAlignment] is [MainAxisAlignment.end], the + /// children are placed at the end of the main axis. + final MainAxisAlignment mainAxisAlignment; + + /// The alignment of the children along the cross axis. + /// + /// For example, if [crossAxisAlignment] is [CrossAxisAlignment.end], the + /// children are placed at the end of the cross axis. + final CrossAxisAlignment crossAxisAlignment; + @override + // ignore: library_private_types_in_public_api _OverflowViewElement createElement() { return _OverflowViewElement(this); } @@ -99,6 +121,8 @@ class OverflowView extends MultiChildRenderObjectWidget { direction: direction, spacing: spacing, layoutBehavior: _layoutBehavior, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, ); } @@ -110,19 +134,21 @@ class OverflowView extends MultiChildRenderObjectWidget { renderObject ..direction = direction ..spacing = spacing - ..layoutBehavior = _layoutBehavior; + ..layoutBehavior = _layoutBehavior + ..mainAxisAlignment = mainAxisAlignment + ..crossAxisAlignment = crossAxisAlignment; } } class _OverflowViewElement extends MultiChildRenderObjectElement { - _OverflowViewElement(OverflowView widget) : super(widget); + _OverflowViewElement(OverflowView super.widget); @override void debugVisitOnstageChildren(ElementVisitor visitor) { - children.forEach((element) { + for (var element in children) { if (element.renderObject?.isOnstage == true) { visitor(element); } - }); + } } } diff --git a/pubspec.yaml b/pubspec.yaml index ca8ee6a..4b28d6c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,3 +15,4 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^6.0.0 diff --git a/test/overflow_view_test.dart b/test/overflow_view_test.dart index d31df95..d73d2d8 100644 --- a/test/overflow_view_test.dart +++ b/test/overflow_view_test.dart @@ -4,9 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:overflow_view/overflow_view.dart'; void main() { - testWidgets( - 'the overflow indicator is not built if there is enough room (except for flexible)', + 'the overflow indicator is not built if there is enough room', (tester) async { int buildCount = 0; await tester.pumpWidget( @@ -39,7 +38,7 @@ void main() { ), ), ); - expect(buildCount, 1); + expect(buildCount, 0); }, ); @@ -378,10 +377,7 @@ void main() { } class _Text extends StatelessWidget { - const _Text( - this.text, { - Key? key, - }) : super(key: key); + const _Text(this.text); final String text;