From 2662ea5283391ca53dbf833d743d033ec7ff4402 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Mon, 7 Mar 2016 11:03:48 -0800 Subject: [PATCH] Added support for List leave-behind items --- .../lib/demo/leave_behind_demo.dart | 149 ++++++++++++++++++ .../material_gallery/lib/gallery/home.dart | 2 + .../material_gallery/lib/gallery/section.dart | 2 +- examples/widgets/card_collection.dart | 1 + .../flutter/lib/src/material/list_item.dart | 3 +- .../flutter/lib/src/widgets/dismissable.dart | 81 +++++++--- .../flutter/test/widget/dismissable_test.dart | 1 + 7 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 examples/material_gallery/lib/demo/leave_behind_demo.dart diff --git a/examples/material_gallery/lib/demo/leave_behind_demo.dart b/examples/material_gallery/lib/demo/leave_behind_demo.dart new file mode 100644 index 0000000000..49337fb945 --- /dev/null +++ b/examples/material_gallery/lib/demo/leave_behind_demo.dart @@ -0,0 +1,149 @@ +// Copyright 2016 The Chromium Authors. 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'; + +enum LeaveBehindDemoAction { + reset, + horizontalSwipe, + leftSwipe, + rightSwipe +} + +class LeaveBehindItem { + LeaveBehindItem({ this.index, this.name, this.subject, this.body }); + + LeaveBehindItem.from(LeaveBehindItem item) + : index = item.index, name = item.name, subject = item.subject, body = item.body; + + final int index; + final String name; + final String subject; + final String body; +} + +class LeaveBehindDemo extends StatefulComponent { + LeaveBehindDemo({ Key key }) : super(key: key); + + LeaveBehindDemoState createState() => new LeaveBehindDemoState(); +} + +class LeaveBehindDemoState extends State { + final GlobalKey _scaffoldKey = new GlobalKey(); + DismissDirection _dismissDirection = DismissDirection.horizontal; + List leaveBehindItems; + + void initListItems() { + leaveBehindItems = new List.generate(16, (int index) { + return new LeaveBehindItem( + index: index, + name: 'Item $index Sender', + subject: 'Subject: $index', + body: "[$index] first line of the message's body..." + ); + }); + } + + void initState() { + super.initState(); + initListItems(); + } + + void handleDemoAction(LeaveBehindDemoAction action) { + switch(action) { + case LeaveBehindDemoAction.reset: + initListItems(); + break; + case LeaveBehindDemoAction.horizontalSwipe: + _dismissDirection = DismissDirection.horizontal; + break; + case LeaveBehindDemoAction.leftSwipe: + _dismissDirection = DismissDirection.left; + break; + case LeaveBehindDemoAction.rightSwipe: + _dismissDirection = DismissDirection.right; + break; + } + } + + Widget buildItem(LeaveBehindItem item) { + final ThemeData theme = Theme.of(context); + return new Dismissable( + key: new ObjectKey(item), + direction: _dismissDirection, + onDismissed: (DismissDirection direction) { + setState(() { + leaveBehindItems.remove(item); + }); + final String action = (direction == DismissDirection.left) ? 'archived' : 'deleted'; + _scaffoldKey.currentState.showSnackBar(new SnackBar( + content: new Text('You $action item ${item.index}') + )); + }, + background: new Container( + decoration: new BoxDecoration(backgroundColor: theme.primaryColor), + child: new ListItem( + left: new Icon(icon: Icons.delete, color: Colors.white, size: 36.0) + ) + ), + secondaryBackground: new Container( + decoration: new BoxDecoration(backgroundColor: theme.primaryColor), + child: new ListItem( + right: new Icon(icon: Icons.archive, color: Colors.white, size: 36.0) + ) + ), + child: new Container( + decoration: new BoxDecoration( + backgroundColor: theme.canvasColor, + border: new Border(bottom: new BorderSide(color: theme.dividerColor)) + ), + child: new ListItem( + primary: new Text(item.name), + secondary: new Text('${item.subject}\n${item.body}'), + isThreeLine: true + ) + ) + ); + } + + Widget build(BuildContext context) { + return new Scaffold( + key: _scaffoldKey, + toolBar: new ToolBar( + center: new Text('Swipe Items to Dismiss'), + right: [ + new PopupMenuButton( + onSelected: handleDemoAction, + items: [ + new PopupMenuItem( + value: LeaveBehindDemoAction.reset, + child: new Text('Reset the list') + ), + new PopupMenuDivider(), + new CheckedPopupMenuItem( + value: LeaveBehindDemoAction.horizontalSwipe, + checked: _dismissDirection == DismissDirection.horizontal, + child: new Text('Hoizontal swipe') + ), + new CheckedPopupMenuItem( + value: LeaveBehindDemoAction.leftSwipe, + checked: _dismissDirection == DismissDirection.left, + child: new Text('Only swipe left') + ), + new CheckedPopupMenuItem( + value: LeaveBehindDemoAction.rightSwipe, + checked: _dismissDirection == DismissDirection.right, + child: new Text('Only swipe right') + ) + ] + ) + ] + ), + body: new Block( + padding: new EdgeDims.all(4.0), + children: leaveBehindItems.map(buildItem).toList() + ) + ); + } +} diff --git a/examples/material_gallery/lib/gallery/home.dart b/examples/material_gallery/lib/gallery/home.dart index f4ec62ba4c..29fb32d55c 100644 --- a/examples/material_gallery/lib/gallery/home.dart +++ b/examples/material_gallery/lib/gallery/home.dart @@ -19,6 +19,7 @@ import '../demo/drop_down_demo.dart'; import '../demo/fitness_demo.dart'; import '../demo/grid_list_demo.dart'; import '../demo/icons_demo.dart'; +import '../demo/leave_behind_demo.dart'; import '../demo/list_demo.dart'; import '../demo/modal_bottom_sheet_demo.dart'; import '../demo/menu_demo.dart'; @@ -107,6 +108,7 @@ class GalleryHomeState extends State { new GalleryDemo(title: 'Floating Action Button', builder: () => new TabsFabDemo()), new GalleryDemo(title: 'Grid', builder: () => new GridListDemo()), new GalleryDemo(title: 'Icons', builder: () => new IconsDemo()), + new GalleryDemo(title: 'Leave-behind List Items', builder: () => new LeaveBehindDemo()), new GalleryDemo(title: 'List', builder: () => new ListDemo()), new GalleryDemo(title: 'Modal Bottom Sheet', builder: () => new ModalBottomSheetDemo()), new GalleryDemo(title: 'Menus', builder: () => new MenuDemo()), diff --git a/examples/material_gallery/lib/gallery/section.dart b/examples/material_gallery/lib/gallery/section.dart index 7fd2a1cd97..2081fb1b2f 100644 --- a/examples/material_gallery/lib/gallery/section.dart +++ b/examples/material_gallery/lib/gallery/section.dart @@ -67,7 +67,7 @@ class GallerySection extends StatelessComponent { primarySwatch: colors ); final TextStyle titleTextStyle = theme.text.title.copyWith( - color: theme.brightness == ThemeBrightness.dark ? Colors.black : Colors.white + color: Colors.white ); return new Flexible( child: new GestureDetector( diff --git a/examples/widgets/card_collection.dart b/examples/widgets/card_collection.dart index 2cf75db445..82d9550d6d 100644 --- a/examples/widgets/card_collection.dart +++ b/examples/widgets/card_collection.dart @@ -296,6 +296,7 @@ class CardCollectionState extends State { CardModel cardModel = _cardModels[index]; Widget card = new Dismissable( + key: new ObjectKey(cardModel), direction: _dismissDirection, onResized: () { _invalidator([index]); }, onDismissed: (DismissDirection direction) { dismissCard(cardModel); }, diff --git a/packages/flutter/lib/src/material/list_item.dart b/packages/flutter/lib/src/material/list_item.dart index 84d4202ee2..f3ec9f23f8 100644 --- a/packages/flutter/lib/src/material/list_item.dart +++ b/packages/flutter/lib/src/material/list_item.dart @@ -28,7 +28,6 @@ class ListItem extends StatelessComponent { this.onTap, this.onLongPress }) : super(key: key) { - assert(primary != null); assert(isThreeLine ? secondary != null : true); } @@ -117,7 +116,7 @@ class ListItem extends StatelessComponent { final Widget primaryLine = new DefaultTextStyle( style: primaryTextStyle(context), - child: primary + child: primary ?? new Container() ); Widget center = primaryLine; if (isTwoLine || isThreeLine) { diff --git a/packages/flutter/lib/src/widgets/dismissable.dart b/packages/flutter/lib/src/widgets/dismissable.dart index 08058a43cd..7676f4be84 100644 --- a/packages/flutter/lib/src/widgets/dismissable.dart +++ b/packages/flutter/lib/src/widgets/dismissable.dart @@ -38,24 +38,47 @@ enum DismissDirection { down } -/// Can be dismissed by dragging in one or more directions. +/// Can be dismissed by dragging in the indicated [direction]. /// -/// The child is draggable in the indicated direction(s). When released (or -/// flung), the child disappears off the edge and the dismissable widget +/// Dragging or flinging this widget in the [DismissDirection] causes the child +/// to slide out of view. Following the slide animation, the Dismissable widget /// animates its height (or width, whichever is perpendicular to the dismiss /// direction) to zero. +/// +/// Backgrounds can be used to implement the "leave-behind" idiom. If a background +/// is specified it is stacked behind the Dismissable's child and is exposed when +/// the child moves. +/// +/// The [onDimissed] callback runs after Dismissable's size has collapsed to zero. +/// If the Dismissable is a list item, it must have a key that distinguishes it from +/// the other items and its onDismissed callback must remove the item from the list. class Dismissable extends StatefulComponent { Dismissable({ Key key, this.child, + this.background, + this.secondaryBackground, this.onResized, this.onDismissed, this.direction: DismissDirection.horizontal - }) : super(key: key); + }) : super(key: key) { + assert(key != null); + assert(secondaryBackground != null ? background != null : true); + } final Widget child; - /// Called when the widget changes size (i.e., when contracting after being dismissed). + /// A widget that is stacked behind the child. If secondaryBackground is also + /// specified then this widget only appears when the child has been dragged + /// down or to the right. + final Widget background; + + /// A widget that is stacked behind the child and is exposed when the child + /// has been dragged up or to the left. It may only be specified when background + /// has also been specified. + final Widget secondaryBackground; + + /// Called when the widget changes size (i.e., when contracting before being dismissed). final VoidCallback onResized; /// Called when the widget has been dismissed, after finishing resizing. @@ -96,6 +119,12 @@ class _DismissableState extends State { || config.direction == DismissDirection.right; } + DismissDirection get _dismissDirection { + if (_directionIsXAxis) + return _dragExtent > 0 ? DismissDirection.right : DismissDirection.left; + return _dragExtent > 0 ? DismissDirection.down : DismissDirection.up; + } + bool get _isActive { return _dragUnderway || _moveController.isAnimating; } @@ -235,12 +264,7 @@ class _DismissableState extends State { void _handleResizeProgressChanged() { if (_resizeController.isCompleted) { if (config.onDismissed != null) { - DismissDirection direction; - if (_directionIsXAxis) - direction = _dragExtent > 0 ? DismissDirection.right : DismissDirection.left; - else - direction = _dragExtent > 0 ? DismissDirection.down : DismissDirection.up; - config.onDismissed(direction); + config.onDismissed(_dismissDirection); } } else { if (config.onResized != null) @@ -249,31 +273,53 @@ class _DismissableState extends State { } Widget build(BuildContext context) { + Widget background = config.background; + if (config.secondaryBackground != null) { + final DismissDirection direction = _dismissDirection; + if (direction == DismissDirection.left || direction == DismissDirection.up) + background = config.secondaryBackground; + } + if (_resizeAnimation != null) { // we've been dragged aside, and are now resizing. assert(() { if (_resizeAnimation.status != AnimationStatus.forward) { assert(_resizeAnimation.status == AnimationStatus.completed); throw new WidgetError( - 'Dismissable widget completed its resize animation without being removed from the tree.\n' - 'Make sure to implement the onDismissed handler and to immediately remove the Dismissable ' + 'A dismissed Dismissable widget is still part of the tree.\n' + + 'Make sure to implement the onDismissed handler and to immediately remove the Dismissable\n' + 'widget from the application once that handler has fired.' ); } return true; }); + return new AnimatedBuilder( animation: _resizeAnimation, builder: (BuildContext context, Widget child) { return new SizedBox( width: !_directionIsXAxis ? _resizeAnimation.value : null, - height: _directionIsXAxis ? _resizeAnimation.value : null + height: _directionIsXAxis ? _resizeAnimation.value : null, + child: background ); } ); } - // we are not resizing. (we may be being dragged aside.) + Widget backgroundAndChild = new SlideTransition( + position: _moveAnimation, + child: config.child + ); + if (background != null) { + backgroundAndChild = new Stack( + children: [ + new Positioned(left: 0.0, top: 0.0, bottom: 0.0, right: 0.0, child: background), + new Viewport(child: backgroundAndChild) + ] + ); + } + + // We are not resizing but we may be being dragging in config.direction. return new GestureDetector( onHorizontalDragStart: _directionIsXAxis ? _handleDragStart : null, onHorizontalDragUpdate: _directionIsXAxis ? _handleDragUpdate : null, @@ -282,10 +328,7 @@ class _DismissableState extends State { onVerticalDragUpdate: _directionIsXAxis ? null : _handleDragUpdate, onVerticalDragEnd: _directionIsXAxis ? null : _handleDragEnd, behavior: HitTestBehavior.opaque, - child: new SlideTransition( - position: _moveAnimation, - child: config.child - ) + child: backgroundAndChild ); } } diff --git a/packages/flutter/test/widget/dismissable_test.dart b/packages/flutter/test/widget/dismissable_test.dart index 9f53993353..4610f07bd6 100644 --- a/packages/flutter/test/widget/dismissable_test.dart +++ b/packages/flutter/test/widget/dismissable_test.dart @@ -110,6 +110,7 @@ class Test1215DismissableComponent extends StatelessComponent { final String text; Widget build(BuildContext context) { return new Dismissable( + key: new ObjectKey(text), child: new AspectRatio( aspectRatio: 1.0, child: new Text(this.text)