From 47b8610ed9a83d18734e308439956036fff49eaa Mon Sep 17 00:00:00 2001 From: Brett Morgan Date: Tue, 9 Jun 2020 15:58:00 +1000 Subject: [PATCH] Use flutter_simple_treeview (#461) --- .../desktop_photo_search/lib/main.dart | 42 ++- .../lib/src/model/photo_search_model.dart | 37 +- .../lib/src/serializers.dart | 2 +- .../lib/src/widgets/data_tree.dart | 350 ------------------ .../lib/src/widgets/photo_details.dart | 157 ++++---- .../desktop_photo_search/macos/Podfile.lock | 4 +- .../contents.xcworkspacedata | 7 - .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../desktop_photo_search/pubspec.lock | 102 ++--- .../desktop_photo_search/pubspec.yaml | 5 +- 10 files changed, 187 insertions(+), 521 deletions(-) delete mode 100644 experimental/desktop_photo_search/lib/src/widgets/data_tree.dart delete mode 100644 experimental/desktop_photo_search/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/experimental/desktop_photo_search/lib/main.dart b/experimental/desktop_photo_search/lib/main.dart index 6287df304..8b0a1c844 100644 --- a/experimental/desktop_photo_search/lib/main.dart +++ b/experimental/desktop_photo_search/lib/main.dart @@ -4,9 +4,10 @@ import 'dart:io'; +import 'package:file_chooser/file_chooser.dart' as file_chooser; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:file_chooser/file_chooser.dart' as file_chooser; +import 'package:flutter_simple_treeview/flutter_simple_treeview.dart'; import 'package:logging/logging.dart'; import 'package:menubar/menubar.dart' as menubar; import 'package:meta/meta.dart'; @@ -14,7 +15,6 @@ import 'package:provider/provider.dart'; import 'src/model/photo_search_model.dart'; import 'src/unsplash/unsplash.dart'; -import 'src/widgets/data_tree.dart'; import 'src/widgets/photo_details.dart'; import 'src/widgets/photo_search_dialog.dart'; import 'src/widgets/split.dart'; @@ -86,7 +86,16 @@ class UnsplashHomePage extends StatelessWidget { ? Split( axis: Axis.horizontal, initialFirstFraction: 0.4, - firstChild: DataTree(photoSearchModel.entries), + firstChild: Scrollbar( + child: SingleChildScrollView( + child: TreeView( + nodes: photoSearchModel.entries + .map(_buildSearchEntry) + .toList(), + indent: 0, + ), + ), + ), secondChild: Center( child: photoSearchModel.selectedPhoto != null ? PhotoDetails( @@ -124,4 +133,31 @@ class UnsplashHomePage extends StatelessWidget { ), ); } + + TreeNode _buildSearchEntry(SearchEntry searchEntry) { + return TreeNode( + content: Expanded( + child: Text(searchEntry.query), + ), + children: searchEntry.photos + .map( + (photo) => TreeNode( + content: Expanded( + child: InkWell( + onTap: () { + searchEntry.model.selectedPhoto = photo; + }, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + 'Photo by ${photo.user.name}', + ), + ), + ), + ), + ), + ) + .toList(), + ); + } } diff --git a/experimental/desktop_photo_search/lib/src/model/photo_search_model.dart b/experimental/desktop_photo_search/lib/src/model/photo_search_model.dart index 633b98902..e6509403d 100644 --- a/experimental/desktop_photo_search/lib/src/model/photo_search_model.dart +++ b/experimental/desktop_photo_search/lib/src/model/photo_search_model.dart @@ -9,43 +9,24 @@ import 'package:meta/meta.dart'; import '../unsplash/photo.dart'; import '../unsplash/unsplash.dart'; -import '../widgets/data_tree.dart' show Entry; import 'search.dart'; -class _PhotoEntry extends Entry { - _PhotoEntry(this._photo, this._model) : super('Photo by ${_photo.user.name}'); - - final Photo _photo; - final PhotoSearchModel _model; - - @override - bool get isSelected => false; - - @override - set isSelected(bool selected) { - _model._setSelectedPhoto(_photo); - } -} - -class _SearchEntry extends Entry { - _SearchEntry(String query, List photos, PhotoSearchModel model) - : super( - query, - List.unmodifiable( - photos.map((photo) => _PhotoEntry(photo, model)), - ), - ); +class SearchEntry { + const SearchEntry(this.query, this.photos, this.model); + final String query; + final List photos; + final PhotoSearchModel model; } class PhotoSearchModel extends ChangeNotifier { PhotoSearchModel(this._client); final Unsplash _client; - List get entries => List.unmodifiable(_entries); - final List _entries = []; + List get entries => List.unmodifiable(_entries); + final List _entries = []; Photo get selectedPhoto => _selectedPhoto; - void _setSelectedPhoto(Photo photo) { + set selectedPhoto(Photo photo) { _selectedPhoto = photo; notifyListeners(); } @@ -63,7 +44,7 @@ class PhotoSearchModel extends ChangeNotifier { ..results.addAll(result.results); }); - _entries.add(_SearchEntry(query, search.results.toList(), this)); + _entries.add(SearchEntry(query, search.results.toList(), this)); notifyListeners(); } diff --git a/experimental/desktop_photo_search/lib/src/serializers.dart b/experimental/desktop_photo_search/lib/src/serializers.dart index 696b8aefc..6153a3d45 100644 --- a/experimental/desktop_photo_search/lib/src/serializers.dart +++ b/experimental/desktop_photo_search/lib/src/serializers.dart @@ -2,9 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:built_collection/built_collection.dart'; import 'package:built_value/serializer.dart'; import 'package:built_value/standard_json_plugin.dart'; -import 'package:built_collection/built_collection.dart'; import 'model/search.dart'; import 'unsplash/api_error.dart'; diff --git a/experimental/desktop_photo_search/lib/src/widgets/data_tree.dart b/experimental/desktop_photo_search/lib/src/widgets/data_tree.dart deleted file mode 100644 index e43869e00..000000000 --- a/experimental/desktop_photo_search/lib/src/widgets/data_tree.dart +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright 2019 The Flutter team. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -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 - 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 = Theme.of(context).colorScheme; - - final backgroundColor = controller - .drive(CurveTween(curve: Curves.fastOutSlowIn)) - .drive(ColorTween( - begin: colorScheme.primary.withOpacity(0.0), - end: colorScheme.primary.withOpacity(0.08), - )); - - final 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: (context, 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 [], - 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 onExpansionChanged; - final ValueChanged onSelectionChanged; - final List children; - final Color backgroundColor; - final bool initiallyExpanded; - final double indent; - final double height; - - @override - _DataTreeNodeState createState() => _DataTreeNodeState(); -} - -class _DataTreeNodeState extends State - with SingleTickerProviderStateMixin { - static final Animatable _easeInTween = - CurveTween(curve: Curves.easeIn); - static final Animatable _halfTween = - Tween(begin: 0.0, end: 0.25); - - AnimationController _controller; - Animation _iconTurns; - Animation _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) as bool; - 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((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) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DataTreeInkWell( - onTap: _handleNodeTap, - isSelected: _isExpanded, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox(width: widget.indent), - RotationTransition( - turns: _iconTurns, - child: Icon( - Icons.arrow_right, - size: 20, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.folder, - size: 20, - ), - const SizedBox(width: 16), - widget.title, - ], - ), - ), - if (child != null) // If child == null, then this DataNode is closed. - const DataTreeRule(), - if (child != null) - ClipRect( - child: Align( - alignment: Alignment.topLeft, - heightFactor: _heightFactor.value, - child: child, - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textColor = colorScheme.onBackground.withOpacity(0.87); - - final closed = !_isExpanded && _controller.isDismissed; - - return widget.children.isEmpty - // Leaf node. - ? DataTreeInkWell( - onTap: _handleLeafTap, - isSelected: _isSelected, - child: Row( - children: [ - SizedBox(width: widget.indent), - Icon( - Icons.web_asset, - size: 20, - ), - const SizedBox(width: 16), - DefaultTextStyle( - style: Theme.of(context).textTheme.bodyText2.copyWith( - color: textColor, - ), - child: widget.title, - ), - ], - ), - ) - // Not a leaf node. - : AnimatedBuilder( - animation: _controller.view, - builder: _buildChildren, - child: closed - ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (int index = 0; - index < (widget.children.length * 2) - 1; - index += 1) - (index % 2 == 1) - ? const DataTreeRule() - : widget.children[index ~/ 2], - ], - ), - ); - } -} - -// One entry in the multilevel list displayed by this app. -class Entry { - Entry(this.title, [this.children = const []]); - final String title; - final List 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 { - Widget _buildNodes(Entry root, double indent) { - return DataTreeNode( - key: PageStorageKey(root), - onSelectionChanged: (isSelected) { - setState(() { - root.isSelected = isSelected; - }); - }, - title: Container( - alignment: AlignmentDirectional.centerStart, - height: 36, - child: Text(root.title), - ), - indent: indent, - children: root.children.map((entry) { - return _buildNodes(entry, indent + 28); - }).toList(), - ); - } - - @override - Widget build(BuildContext context) { - return _buildNodes(widget.entry, 16); - } -} - -class DataTree extends StatelessWidget { - DataTree(this.entries) - : assert(entries != null), - assert(entries.isNotEmpty); - final List entries; - - @override - Widget build(BuildContext context) => ListView.builder( - itemCount: entries.length * 2 - 1, - itemBuilder: (context, index) { - if (index % 2 == 1) { - return const DataTreeRule(); - } - return EntryItem( - entry: entries[index ~/ 2], - ); - }, - ); -} diff --git a/experimental/desktop_photo_search/lib/src/widgets/photo_details.dart b/experimental/desktop_photo_search/lib/src/widgets/photo_details.dart index 132a3cdd7..9e3ff28c4 100644 --- a/experimental/desktop_photo_search/lib/src/widgets/photo_details.dart +++ b/experimental/desktop_photo_search/lib/src/widgets/photo_details.dart @@ -29,96 +29,101 @@ class PhotoDetails extends StatefulWidget { } class _PhotoDetailsState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { Widget _buildPhotoAttribution(BuildContext context) { - return RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyText2, - children: [ - const TextSpan(text: 'Photo by '), - TextSpan( - text: widget.photo.user.name, - style: const TextStyle( - decoration: TextDecoration.underline, + return Expanded( + child: RichText( + overflow: TextOverflow.fade, + text: TextSpan( + style: Theme.of(context).textTheme.bodyText2, + children: [ + const TextSpan(text: 'Photo by '), + TextSpan( + text: widget.photo.user.name, + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final url = Uri.encodeFull( + 'https://unsplash.com/@${widget.photo.user.username}?utm_source=$unsplashAppName&utm_medium=referral'); + if (await url_launcher.canLaunch(url)) { + await url_launcher.launch(url); + } + }, ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - final url = Uri.encodeFull( - 'https://unsplash.com/@${widget.photo.user.username}?utm_source=$unsplashAppName&utm_medium=referral'); - if (await url_launcher.canLaunch(url)) { - await url_launcher.launch(url); - } - }, - ), - const TextSpan(text: ' on '), - TextSpan( - text: 'Unsplash', - style: const TextStyle( - decoration: TextDecoration.underline, + const TextSpan(text: ' on '), + TextSpan( + text: 'Unsplash', + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + if (await url_launcher.canLaunch(_unsplashHomepage)) { + await url_launcher.launch(_unsplashHomepage); + } + }, ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - if (await url_launcher.canLaunch(_unsplashHomepage)) { - await url_launcher.launch(_unsplashHomepage); - } - }, - ), - ], + ], + ), ), ); } @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - Card( - shape: ContinuousRectangleBorder( - side: BorderSide(color: Colors.black12), - borderRadius: BorderRadius.circular(4), - ), - child: AnimatedSize( - vsync: this, - duration: Duration(milliseconds: 750), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: 400, - minHeight: 400, - ), - child: FadeInImage.memoryNetwork( - placeholder: kTransparentImage, - image: widget.photo.urls.small, + return Scrollbar( + child: SingleChildScrollView( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Card( + shape: ContinuousRectangleBorder( + side: BorderSide(color: Colors.black12), + borderRadius: BorderRadius.circular(4), + ), + child: AnimatedSize( + vsync: this, + duration: Duration(milliseconds: 750), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: 400, + minHeight: 400, + ), + child: FadeInImage.memoryNetwork( + placeholder: kTransparentImage, + image: widget.photo.urls.small, + ), ), ), ), ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildPhotoAttribution(context), - const SizedBox(width: 8), - IconButton( - visualDensity: VisualDensity.compact, - icon: Icon(Icons.cloud_download), - onPressed: () => widget.onPhotoSave(widget.photo), - ), - ], + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildPhotoAttribution(context), + const SizedBox(width: 8), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(Icons.cloud_download), + onPressed: () => widget.onPhotoSave(widget.photo), + ), + ], + ), ), - ), - const SizedBox(height: 48), - ], + const SizedBox(height: 48), + ], + ), ), ), ); diff --git a/experimental/desktop_photo_search/macos/Podfile.lock b/experimental/desktop_photo_search/macos/Podfile.lock index bc73a9f2f..6530ef94a 100644 --- a/experimental/desktop_photo_search/macos/Podfile.lock +++ b/experimental/desktop_photo_search/macos/Podfile.lock @@ -32,8 +32,8 @@ SPEC CHECKSUMS: FlutterMacOS: 15bea8a44d2fa024068daa0140371c020b4b6ff9 menubar: 4e3d461d62d775540277ce6639acafe2a111a231 url_launcher: af78307ef9bafff91273b34f1c6c0c86a0004fd7 - url_launcher_macos: 76867a28e24e0b6b98bfd65f157b64108e6d477a + url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4 PODFILE CHECKSUM: d8ba9b3e9e93c62c74a660b46c6fcb09f03991a7 -COCOAPODS: 1.8.4 +COCOAPODS: 1.9.1 diff --git a/experimental/desktop_photo_search/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/experimental/desktop_photo_search/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 764c74b8d..000000000 --- a/experimental/desktop_photo_search/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/experimental/desktop_photo_search/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/experimental/desktop_photo_search/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 920b566c6..82def57d1 100644 --- a/experimental/desktop_photo_search/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/experimental/desktop_photo_search/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ =2.7.0 <3.0.0" - flutter: ">=1.17.0 <2.0.0" + dart: ">=2.9.0-11.0.dev <3.0.0" + flutter: ">=1.19.0-3.0.pre <2.0.0" diff --git a/experimental/desktop_photo_search/pubspec.yaml b/experimental/desktop_photo_search/pubspec.yaml index aa42359db..d2cb183f5 100644 --- a/experimental/desktop_photo_search/pubspec.yaml +++ b/experimental/desktop_photo_search/pubspec.yaml @@ -3,8 +3,8 @@ description: Search for Photos, using the Unsplash API. version: 1.0.0+1 environment: - sdk: ^2.7.0-dev - flutter: ^1.13.1-pre + sdk: ^2.9.0-11.0.dev + flutter: ^1.19.0-3.0.pre dependencies: flutter: @@ -17,6 +17,7 @@ dependencies: url: https://github.com/google/flutter-desktop-embedding.git path: plugins/file_chooser logging: ^0.11.3+2 + flutter_simple_treeview: ^2.0.1 menubar: git: url: https://github.com/google/flutter-desktop-embedding.git