Created
November 19, 2019 10:56
-
-
Save HansMuller/97bce0dc4fcc6ca48c29d2ef50190e42 to your computer and use it in GitHub Desktop.
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/scheduler.dart'; | |
| class DataTreeRule extends StatelessWidget { | |
| const DataTreeRule({ Key key }) : super(key: key); | |
| @override | |
| Widget build(BuildContext context) { | |
| return Container( | |
| height: 2, | |
| color: Theme.of(context).colorScheme.onBackground.withOpacity(0.1), | |
| ); | |
| } | |
| } | |
| class DataTreeInkWell extends StatefulWidget { | |
| const DataTreeInkWell({ Key key, this.onTap, this.isSelected, this.child }) : super(key: key); | |
| final VoidCallback onTap; | |
| final bool isSelected; | |
| final Widget child; | |
| @override | |
| _DataTreeInkWellState createState() => _DataTreeInkWellState(); | |
| } | |
| class _DataTreeInkWellState extends State<DataTreeInkWell> with SingleTickerProviderStateMixin { | |
| AnimationController controller; | |
| Animation selectionColor; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| controller = AnimationController(duration: const Duration(milliseconds: 100), vsync: this); | |
| } | |
| @override | |
| void dispose() { | |
| controller.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| void didUpdateWidget(DataTreeInkWell oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.isSelected) | |
| controller.forward(); | |
| else | |
| controller.reverse(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final ColorScheme colorScheme = Theme.of(context).colorScheme; | |
| final Animation<Color> backgroundColor = controller | |
| .drive(CurveTween(curve: Curves.fastOutSlowIn)) | |
| .drive(ColorTween( | |
| begin: colorScheme.primary.withOpacity(0.0), | |
| end: colorScheme.primary.withOpacity(0.08), | |
| )); | |
| final Animation<Color> iconColor = controller | |
| .drive(CurveTween(curve: Curves.fastOutSlowIn)) | |
| .drive(ColorTween( | |
| begin: colorScheme.onBackground.withOpacity(0.54), | |
| end: colorScheme.onBackground.withOpacity(0.87), | |
| )); | |
| return AnimatedBuilder( | |
| animation: controller, | |
| builder: (BuildContext context, Widget child) { | |
| return IconTheme( | |
| data: IconThemeData(color: iconColor.value), | |
| child: Container( | |
| color: backgroundColor.value, | |
| child: child, | |
| ), | |
| ); | |
| }, | |
| child: InkWell( | |
| onTap: widget.onTap, | |
| splashFactory: InkRipple.splashFactory, | |
| splashColor: colorScheme.primary.withOpacity(0.14), | |
| highlightColor: Colors.transparent, | |
| child: widget.child, | |
| ), | |
| ); | |
| } | |
| } | |
| class DataTreeNode extends StatefulWidget { | |
| const DataTreeNode({ | |
| Key key, | |
| this.leading, | |
| @required this.title, | |
| this.backgroundColor, | |
| this.onExpansionChanged, | |
| this.onSelectionChanged, | |
| this.children = const <Widget>[], | |
| this.initiallyExpanded = false, | |
| this.indent = 0, | |
| this.height = 36, | |
| }) : assert(initiallyExpanded != null), | |
| assert(indent != null && indent >= 0), | |
| super(key: key); | |
| final Widget leading; | |
| final Widget title; | |
| final ValueChanged<bool> onExpansionChanged; | |
| final ValueChanged<bool> onSelectionChanged; | |
| final List<Widget> children; | |
| final Color backgroundColor; | |
| final bool initiallyExpanded; | |
| final double indent; | |
| final double height; | |
| @override | |
| _DataTreeNodeState createState() => _DataTreeNodeState(); | |
| } | |
| class _DataTreeNodeState extends State<DataTreeNode> with SingleTickerProviderStateMixin { | |
| static final Animatable<double> _easeInTween = CurveTween(curve: Curves.easeIn); | |
| static final Animatable<double> _halfTween = Tween<double>(begin: 0.0, end: 0.25); | |
| AnimationController _controller; | |
| Animation<double> _iconTurns; | |
| Animation<double> _heightFactor; | |
| bool _isExpanded = false; | |
| bool _isSelected = false; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); | |
| _heightFactor = _controller.drive(_easeInTween); | |
| _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); | |
| _isExpanded = PageStorage.of(context)?.readState(context) ?? widget.initiallyExpanded; | |
| if (_isExpanded) | |
| _controller.value = 1.0; | |
| } | |
| @override | |
| void dispose() { | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| void _handleNodeTap() { | |
| setState(() { | |
| _isExpanded = !_isExpanded; | |
| if (_isExpanded) { | |
| _controller.forward(); | |
| } else { | |
| _controller.reverse().then<void>((void value) { | |
| if (!mounted) | |
| return; | |
| setState(() { | |
| // Rebuild without widget.children. | |
| }); | |
| }); | |
| } | |
| PageStorage.of(context)?.writeState(context, _isExpanded); | |
| }); | |
| if (widget.onExpansionChanged != null) | |
| widget.onExpansionChanged(_isExpanded); | |
| } | |
| void _handleLeafTap() { | |
| _isSelected = !_isSelected; | |
| if (widget.onSelectionChanged != null) | |
| widget.onSelectionChanged(_isSelected); | |
| } | |
| Widget _buildChildren(BuildContext context, Widget child) { | |
| final ColorScheme colorScheme = Theme.of(context).colorScheme; | |
| return Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: <Widget>[ | |
| DataTreeInkWell( | |
| onTap: _handleNodeTap, | |
| isSelected: _isExpanded, | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.start, | |
| children: <Widget>[ | |
| SizedBox(width: widget.indent), | |
| RotationTransition( | |
| turns: _iconTurns, | |
| child: Icon( | |
| Icons.arrow_right, | |
| size: 20, | |
| ), | |
| ), | |
| SizedBox(width: 8), | |
| Icon( | |
| Icons.folder, | |
| size: 20, | |
| ), | |
| SizedBox(width: 16), | |
| widget.title, | |
| ], | |
| ), | |
| ), | |
| if (child != null) // If child == null, then this DataNode is closed. | |
| DataTreeRule(), | |
| if (child != null) | |
| ClipRect( | |
| child: Align( | |
| alignment: Alignment.topLeft, | |
| heightFactor: _heightFactor.value, | |
| child: child, | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final ColorScheme colorScheme = Theme.of(context).colorScheme; | |
| final Color textColor = colorScheme.onBackground.withOpacity(0.87); | |
| final bool closed = !_isExpanded && _controller.isDismissed; | |
| return widget.children.isEmpty | |
| // Leaf node. | |
| ? DataTreeInkWell( | |
| onTap: _handleLeafTap, | |
| isSelected: _isSelected, | |
| child: Row( | |
| children: <Widget>[ | |
| SizedBox(width: widget.indent), | |
| Icon( | |
| Icons.web_asset, | |
| size: 20, | |
| ), | |
| SizedBox(width: 16), | |
| DefaultTextStyle( | |
| style: Theme.of(context).textTheme.body1.copyWith( | |
| color: textColor, | |
| ), | |
| child: widget.title, | |
| ), | |
| ], | |
| ), | |
| ) | |
| // Not a leaf node. | |
| : AnimatedBuilder( | |
| animation: _controller.view, | |
| builder: _buildChildren, | |
| child: closed ? null : Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: <Widget>[ | |
| for (int index = 0; index < (widget.children.length * 2) - 1; index += 1) | |
| (index % 2 == 1) ? const DataTreeRule() : widget.children[index ~/ 2], | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| // The entire multilevel list displayed by this app. | |
| final List<Entry> data = <Entry>[ | |
| Entry('Chapter A', | |
| <Entry>[ | |
| Entry('Section A0', | |
| <Entry>[ | |
| Entry('Item A0.1'), | |
| Entry('Item A0.2'), | |
| Entry('Item A0.3', | |
| <Entry>[ | |
| Entry('Item A0.3.a'), | |
| Entry('Item A0.3.b'), | |
| Entry('Item A0.3.c'), | |
| Entry('Item A0.3.d'), | |
| Entry('Item A0.3.e'), | |
| Entry('Item A0.3.f'), | |
| ], | |
| ), | |
| ], | |
| ), | |
| Entry('Section A1'), | |
| Entry('Section A2'), | |
| ], | |
| ), | |
| Entry('Chapter B', | |
| <Entry>[ | |
| Entry('Section B0'), | |
| Entry('Section B1'), | |
| ], | |
| ), | |
| Entry('Chapter C', | |
| <Entry>[ | |
| Entry('Section C0'), | |
| Entry('Section C1'), | |
| Entry('Section C2', | |
| <Entry>[ | |
| Entry('Item C2.0'), | |
| Entry('Item C2.1'), | |
| Entry('Item C2.2'), | |
| Entry('Item C2.3'), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ]; | |
| // One entry in the multilevel list displayed by this app. | |
| class Entry { | |
| Entry(this.title, [this.children = const <Entry>[]]); | |
| final String title; | |
| final List<Entry> children; | |
| bool isSelected = false; | |
| bool isEnabled = true; | |
| } | |
| // A visualization of one Entry based on DataTreeNode. | |
| class EntryItem extends StatefulWidget { | |
| const EntryItem({ Key key, this.entry }) : super(key: key); | |
| final Entry entry; | |
| @override | |
| _EntryItemState createState() => _EntryItemState(); | |
| } | |
| class _EntryItemState extends State<EntryItem> { | |
| Widget _buildNodes(Entry root, double indent) { | |
| return DataTreeNode( | |
| key: PageStorageKey<Entry>(root), | |
| onSelectionChanged: (bool isSelected) { | |
| setState(() { | |
| root.isSelected = isSelected; | |
| }); | |
| }, | |
| title: Container( | |
| alignment: AlignmentDirectional.centerStart, | |
| height: 36, | |
| child: Text(root.title), | |
| ), | |
| indent: indent, | |
| children: root.children.map<Widget>((Entry entry) { | |
| return _buildNodes(entry, indent + 28); | |
| }).toList(), | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return _buildNodes(widget.entry, 16); | |
| } | |
| } | |
| class DataTreeDemo extends StatefulWidget { | |
| @override | |
| _DataTreeDemoState createState() => _DataTreeDemoState(); | |
| } | |
| class _DataTreeDemoState extends State<DataTreeDemo> { | |
| ThemeMode themeMode = ThemeMode.light; | |
| @override | |
| Widget build(BuildContext context) { | |
| final int entryCount = data.length * 100; | |
| return MaterialApp( | |
| themeMode: themeMode, | |
| theme: ThemeData.from(colorScheme: ColorScheme.light()), | |
| darkTheme: ThemeData.from(colorScheme: ColorScheme.dark()), | |
| home: Builder( | |
| builder: (BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Text('DataTreeNode'), | |
| actions: <Widget>[ | |
| PopupMenuButton<ThemeMode>( | |
| onSelected: (ThemeMode value) { | |
| setState(() { themeMode = value; }); | |
| }, | |
| itemBuilder: (BuildContext context) { | |
| return <PopupMenuItem<ThemeMode>>[ | |
| CheckedPopupMenuItem<ThemeMode>( | |
| value: ThemeMode.dark, | |
| checked: themeMode == ThemeMode.dark, | |
| child: const Text('Dark Mode'), | |
| ), | |
| CheckedPopupMenuItem<ThemeMode>( | |
| value: ThemeMode.light, | |
| checked: themeMode == ThemeMode.light, | |
| child: const Text('Light Mode'), | |
| ), | |
| ]; | |
| }, | |
| ), | |
| ], | |
| ), | |
| backgroundColor: Theme.of(context).colorScheme.background, | |
| body: ListView.builder( | |
| itemCount: entryCount * 2 - 1, | |
| itemBuilder: (BuildContext context, int index) { | |
| if (index % 2 == 1) | |
| return const DataTreeRule(); | |
| final int entryIndex = index ~/ 2; | |
| final Entry templateEntry = data[entryIndex % data.length]; | |
| return EntryItem( | |
| entry: Entry('[${entryIndex % data.length}] ${templateEntry.title}', templateEntry.children), | |
| ); | |
| }, | |
| ), | |
| ); | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| void main() { | |
| timeDilation = 1; | |
| runApp(DataTreeDemo()); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment