From 9f176fc86ea79314d42d447cf7e7b767d67e55b6 Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Thu, 24 Sep 2015 10:39:39 -0700 Subject: [PATCH] Port widgets that depend on scrolling to fn3 --- packages/flutter/lib/src/fn3/date_picker.dart | 403 ++++++++++++ packages/flutter/lib/src/fn3/dialog.dart | 169 +++++ packages/flutter/lib/src/fn3/drawer.dart | 148 +++++ packages/flutter/lib/src/fn3/navigator.dart | 4 +- packages/flutter/lib/src/fn3/snack_bar.dart | 107 +++ packages/flutter/lib/src/fn3/tabs.dart | 620 ++++++++++++++++++ 6 files changed, 1449 insertions(+), 2 deletions(-) create mode 100644 packages/flutter/lib/src/fn3/date_picker.dart create mode 100644 packages/flutter/lib/src/fn3/dialog.dart create mode 100644 packages/flutter/lib/src/fn3/drawer.dart create mode 100644 packages/flutter/lib/src/fn3/snack_bar.dart create mode 100644 packages/flutter/lib/src/fn3/tabs.dart diff --git a/packages/flutter/lib/src/fn3/date_picker.dart b/packages/flutter/lib/src/fn3/date_picker.dart new file mode 100644 index 0000000000..ad09c13c65 --- /dev/null +++ b/packages/flutter/lib/src/fn3/date_picker.dart @@ -0,0 +1,403 @@ +// Copyright 2015 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 'dart:async'; + +import 'package:intl/date_symbols.dart'; +import 'package:intl/intl.dart'; +import 'package:sky/material.dart'; +import 'package:sky/painting.dart'; +import 'package:sky/services.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/ink_well.dart'; +import 'package:sky/src/fn3/scrollable.dart'; +import 'package:sky/src/fn3/theme.dart'; + +typedef void DatePickerValueChanged(DateTime dateTime); + +enum DatePickerMode { day, year } + +typedef void DatePickerModeChanged(DatePickerMode value); + +class DatePicker extends StatefulComponent { + DatePicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate + }) { + assert(selectedDate != null); + assert(firstDate != null); + assert(lastDate != null); + } + + final DateTime selectedDate; + final DatePickerValueChanged onChanged; + final DateTime firstDate; + final DateTime lastDate; + + DatePickerState createState() => new DatePickerState(this); +} + +class DatePickerState extends ComponentState { + DatePickerState(DatePicker config) : super(config); + + DatePickerMode _mode = DatePickerMode.day; + + void _handleModeChanged(DatePickerMode mode) { + userFeedback.performHapticFeedback(HapticFeedbackType.VIRTUAL_KEY); + setState(() { + _mode = mode; + }); + } + + void _handleYearChanged(DateTime dateTime) { + userFeedback.performHapticFeedback(HapticFeedbackType.VIRTUAL_KEY); + setState(() { + _mode = DatePickerMode.day; + }); + if (config.onChanged != null) + config.onChanged(dateTime); + } + + void _handleDayChanged(DateTime dateTime) { + userFeedback.performHapticFeedback(HapticFeedbackType.VIRTUAL_KEY); + if (config.onChanged != null) + config.onChanged(dateTime); + } + + static const double _calendarHeight = 210.0; + + Widget build(BuildContext context) { + Widget header = new DatePickerHeader( + selectedDate: config.selectedDate, + mode: _mode, + onModeChanged: _handleModeChanged + ); + Widget picker; + switch (_mode) { + case DatePickerMode.day: + picker = new MonthPicker( + selectedDate: config.selectedDate, + onChanged: _handleDayChanged, + firstDate: config.firstDate, + lastDate: config.lastDate, + itemExtent: _calendarHeight + ); + break; + case DatePickerMode.year: + picker = new YearPicker( + selectedDate: config.selectedDate, + onChanged: _handleYearChanged, + firstDate: config.firstDate, + lastDate: config.lastDate + ); + break; + } + return new Column([ + header, + new Container( + height: _calendarHeight, + child: picker + ) + ], alignItems: FlexAlignItems.stretch); + } + +} + +// Shows the selected date in large font and toggles between year and day mode +class DatePickerHeader extends StatelessComponent { + DatePickerHeader({ this.selectedDate, this.mode, this.onModeChanged }) { + assert(selectedDate != null); + assert(mode != null); + } + + DateTime selectedDate; + DatePickerMode mode; + DatePickerModeChanged onModeChanged; + + void _handleChangeMode(DatePickerMode value) { + if (value != mode) + onModeChanged(value); + } + + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + TextTheme headerTheme; + Color dayColor; + Color yearColor; + switch(theme.primaryColorBrightness) { + case ThemeBrightness.light: + headerTheme = Typography.black; + dayColor = mode == DatePickerMode.day ? Colors.black87 : Colors.black54; + yearColor = mode == DatePickerMode.year ? Colors.black87 : Colors.black54; + break; + case ThemeBrightness.dark: + headerTheme = Typography.white; + dayColor = mode == DatePickerMode.day ? Colors.white87 : Colors.white54; + yearColor = mode == DatePickerMode.year ? Colors.white87 : Colors.white54; + break; + } + TextStyle dayStyle = headerTheme.display3.copyWith(color: dayColor, height: 1.0, fontSize: 100.0); + TextStyle monthStyle = headerTheme.headline.copyWith(color: dayColor, height: 1.0); + TextStyle yearStyle = headerTheme.headline.copyWith(color: yearColor, height: 1.0); + + return new Container( + padding: new EdgeDims.all(10.0), + decoration: new BoxDecoration(backgroundColor: theme.primaryColor), + child: new Column([ + new GestureDetector( + onTap: () => _handleChangeMode(DatePickerMode.day), + child: new Text(new DateFormat("MMM").format(selectedDate).toUpperCase(), style: monthStyle) + ), + new GestureDetector( + onTap: () => _handleChangeMode(DatePickerMode.day), + child: new Text(new DateFormat("d").format(selectedDate), style: dayStyle) + ), + new GestureDetector( + onTap: () => _handleChangeMode(DatePickerMode.year), + child: new Text(new DateFormat("yyyy").format(selectedDate), style: yearStyle) + ) + ]) + ); + } +} + +// Fixed height component shows a single month and allows choosing a day +class DayPicker extends StatelessComponent { + DayPicker({ + this.selectedDate, + this.currentDate, + this.onChanged, + this.displayedMonth + }) { + assert(selectedDate != null); + assert(currentDate != null); + assert(onChanged != null); + assert(displayedMonth != null); + } + + final DateTime selectedDate; + final DateTime currentDate; + final DatePickerValueChanged onChanged; + final DateTime displayedMonth; + + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + TextStyle headerStyle = theme.text.caption.copyWith(fontWeight: FontWeight.w700); + TextStyle monthStyle = headerStyle.copyWith(fontSize: 14.0, height: 24.0 / 14.0); + TextStyle dayStyle = headerStyle.copyWith(fontWeight: FontWeight.w500); + DateFormat dateFormat = new DateFormat(); + DateSymbols symbols = dateFormat.dateSymbols; + + List headers = []; + for (String weekDay in symbols.NARROWWEEKDAYS) { + headers.add(new Text(weekDay, style: headerStyle)); + } + List rows = [ + new Text(new DateFormat("MMMM y").format(displayedMonth), style: monthStyle), + new Flex( + headers, + justifyContent: FlexJustifyContent.spaceAround + ) + ]; + int year = displayedMonth.year; + int month = displayedMonth.month; + // Dart's Date time constructor is very forgiving and will understand + // month 13 as January of the next year. :) + int daysInMonth = new DateTime(year, month + 1).difference(new DateTime(year, month)).inDays; + int firstDay = new DateTime(year, month).day; + int weeksShown = 6; + List days = [ + DateTime.SUNDAY, + DateTime.MONDAY, + DateTime.TUESDAY, + DateTime.WEDNESDAY, + DateTime.THURSDAY, + DateTime.FRIDAY, + DateTime.SATURDAY + ]; + int daySlots = weeksShown * days.length; + List labels = []; + for (int i = 0; i < daySlots; i++) { + // This assumes a start day of SUNDAY, but could be changed. + int day = i - firstDay + 1; + Widget item; + if (day < 1 || day > daysInMonth) { + item = new Text(""); + } else { + // Put a light circle around the selected day + BoxDecoration decoration = null; + if (selectedDate.year == year && + selectedDate.month == month && + selectedDate.day == day) + decoration = new BoxDecoration( + backgroundColor: theme.primarySwatch[100], + shape: Shape.circle + ); + + // Use a different font color for the current day + TextStyle itemStyle = dayStyle; + if (currentDate.year == year && + currentDate.month == month && + currentDate.day == day) + itemStyle = itemStyle.copyWith(color: theme.primaryColor); + + item = new GestureDetector( + onTap: () { + DateTime result = new DateTime(year, month, day); + onChanged(result); + }, + child: new Container( + height: 30.0, + decoration: decoration, + child: new Center( + child: new Text(day.toString(), style: itemStyle) + ) + ) + ); + } + labels.add(new Flexible(child: item)); + } + for (int w = 0; w < weeksShown; w++) { + int startIndex = w * days.length; + rows.add(new Row( + labels.sublist(startIndex, startIndex + days.length) + )); + } + + return new Column(rows); + } +} + +// Scrollable list of DayPickers to allow choosing a month +class MonthPicker extends ScrollableWidgetList { + MonthPicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate, + double itemExtent + }) : super(itemExtent: itemExtent) { + assert(selectedDate != null); + assert(onChanged != null); + assert(lastDate.isAfter(firstDate)); + } + + final DateTime selectedDate; + final DatePickerValueChanged onChanged; + final DateTime firstDate; + final DateTime lastDate; + + MonthPickerState createState() => new MonthPickerState(this); +} + +class MonthPickerState extends ScrollableWidgetListState { + MonthPickerState(MonthPicker config) : super(config) { + _updateCurrentDate(); + } + + DateTime _currentDate; + Timer _timer; + + void _updateCurrentDate() { + _currentDate = new DateTime.now(); + DateTime tomorrow = new DateTime(_currentDate.year, _currentDate.month, _currentDate.day + 1); + Duration timeUntilTomorrow = tomorrow.difference(_currentDate); + timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding + if (_timer != null) + _timer.cancel(); + _timer = new Timer(timeUntilTomorrow, () { + setState(() { + _updateCurrentDate(); + }); + }); + } + + int get itemCount => (config.lastDate.year - config.firstDate.year) * 12 + config.lastDate.month - config.firstDate.month + 1; + + List buildItems(BuildContext context, int start, int count) { + List result = new List(); + DateTime startDate = new DateTime(config.firstDate.year + start ~/ 12, config.firstDate.month + start % 12); + for (int i = 0; i < count; ++i) { + DateTime displayedMonth = new DateTime(startDate.year + i ~/ 12, startDate.month + i % 12); + Widget item = new Container( + height: config.itemExtent, + key: new ObjectKey(displayedMonth), + child: new DayPicker( + selectedDate: config.selectedDate, + currentDate: _currentDate, + onChanged: config.onChanged, + displayedMonth: displayedMonth + ) + ); + result.add(item); + } + return result; + } + + void dispose() { + if (_timer != null) + _timer.cancel(); + } +} + +// Scrollable list of years to allow picking a year +class YearPicker extends ScrollableWidgetList { + YearPicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate + }) : super(itemExtent: 50.0) { + assert(selectedDate != null); + assert(onChanged != null); + assert(lastDate.isAfter(firstDate)); + } + + final DateTime selectedDate; + final DatePickerValueChanged onChanged; + final DateTime firstDate; + final DateTime lastDate; + + YearPickerState createState() => new YearPickerState(this); +} + +class YearPickerState extends ScrollableWidgetListState { + YearPickerState(YearPicker config) : super(config); + + int get itemCount => config.lastDate.year - config.firstDate.year + 1; + + List buildItems(BuildContext context, int start, int count) { + TextStyle style = Theme.of(context).text.body1.copyWith(color: Colors.black54); + List items = new List(); + for(int i = start; i < start + count; i++) { + int year = config.firstDate.year + i; + String label = year.toString(); + Widget item = new GestureDetector( + key: new Key(label), + onTap: () { + DateTime result = new DateTime(year, config.selectedDate.month, config.selectedDate.day); + config.onChanged(result); + }, + child: new InkWell( + child: new Container( + height: config.itemExtent, + decoration: year == config.selectedDate.year ? new BoxDecoration( + backgroundColor: Theme.of(context).primarySwatch[100], + shape: Shape.circle + ) : null, + child: new Center( + child: new Text(label, style: style) + ) + ) + ) + ); + items.add(item); + } + return items; + } +} diff --git a/packages/flutter/lib/src/fn3/dialog.dart b/packages/flutter/lib/src/fn3/dialog.dart new file mode 100644 index 0000000000..5f5438b8b6 --- /dev/null +++ b/packages/flutter/lib/src/fn3/dialog.dart @@ -0,0 +1,169 @@ +// Copyright 2015 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 'dart:async'; + +import 'package:sky/animation.dart'; +import 'package:sky/material.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/focus.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/material.dart'; +import 'package:sky/src/fn3/navigator.dart'; +import 'package:sky/src/fn3/scrollable.dart'; +import 'package:sky/src/fn3/theme.dart'; +import 'package:sky/src/fn3/transitions.dart'; + +typedef Widget DialogBuilder(Navigator navigator); + +/// A material design dialog +/// +/// +class Dialog extends StatelessComponent { + Dialog({ + Key key, + this.title, + this.titlePadding, + this.content, + this.contentPadding, + this.actions, + this.onDismiss + }): super(key: key); + + /// The (optional) title of the dialog is displayed in a large font at the top + /// of the dialog. + final Widget title; + + // Padding around the title; uses material design default if none is supplied + // If there is no title, no padding will be provided + final EdgeDims titlePadding; + + /// The (optional) content of the dialog is displayed in the center of the + /// dialog in a lighter font. + final Widget content; + + // Padding around the content; uses material design default if none is supplied + final EdgeDims contentPadding; + + /// The (optional) set of actions that are displayed at the bottom of the + /// dialog. + final List actions; + + /// An (optional) callback that is called when the dialog is dismissed. + final Function onDismiss; + + Color _getColor(BuildContext context) { + switch (Theme.of(context).brightness) { + case ThemeBrightness.light: + return Colors.white; + case ThemeBrightness.dark: + return Colors.grey[800]; + } + } + + Widget build(BuildContext context) { + + List dialogBody = new List(); + + if (title != null) { + EdgeDims padding = titlePadding; + if (padding == null) + padding = new EdgeDims(24.0, 24.0, content == null ? 20.0 : 0.0, 24.0); + dialogBody.add(new Padding( + padding: padding, + child: new DefaultTextStyle( + style: Theme.of(context).text.title, + child: title + ) + )); + } + + if (content != null) { + EdgeDims padding = contentPadding; + if (padding == null) + padding = const EdgeDims(20.0, 24.0, 24.0, 24.0); + dialogBody.add(new Padding( + padding: padding, + child: new DefaultTextStyle( + style: Theme.of(context).text.subhead, + child: content + ) + )); + } + + if (actions != null) { + dialogBody.add(new Container( + child: new Row(actions, + justifyContent: FlexJustifyContent.end + ) + )); + } + + return new Stack([ + new GestureDetector( + onTap: onDismiss, + child: new Container( + decoration: const BoxDecoration( + backgroundColor: const Color(0x7F000000) + ) + ) + ), + new Center( + child: new Container( + margin: new EdgeDims.symmetric(horizontal: 40.0, vertical: 24.0), + child: new ConstrainedBox( + constraints: new BoxConstraints(minWidth: 280.0), + child: new Material( + level: 4, + color: _getColor(context), + child: new IntrinsicWidth( + child: new Block(dialogBody) + ) + ) + ) + ) + ) + ]); + + } +} + +const Duration _kTransitionDuration = const Duration(milliseconds: 150); + +class DialogRoute extends RouteBase { + DialogRoute({ this.completer, this.builder }); + + final Completer completer; + final RouteBuilder builder; + + Duration get transitionDuration => _kTransitionDuration; + bool get isOpaque => false; + Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance) { + return new FadeTransition( + performance: performance, + opacity: new AnimatedValue(0.0, end: 1.0, curve: easeOut), + child: builder(navigator, this) + ); + } + + void popState([dynamic result]) { + completer.complete(result); + } +} + +Future showDialog(NavigatorState navigator, DialogBuilder builder) { + Completer completer = new Completer(); + navigator.push(new DialogRoute( + completer: completer, + builder: (navigator, route) { + return new Focus( + key: new GlobalObjectKey(route), + autofocus: true, + child: builder(navigator) + ); + } + )); + return completer.future; +} diff --git a/packages/flutter/lib/src/fn3/drawer.dart b/packages/flutter/lib/src/fn3/drawer.dart new file mode 100644 index 0000000000..4172941cde --- /dev/null +++ b/packages/flutter/lib/src/fn3/drawer.dart @@ -0,0 +1,148 @@ +// Copyright 2015 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 'dart:async'; + +import 'package:sky/animation.dart'; +import 'package:sky/material.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/navigator.dart'; +import 'package:sky/src/fn3/scrollable.dart'; +import 'package:sky/src/fn3/theme.dart'; +import 'package:sky/src/fn3/transitions.dart'; + +// TODO(eseidel): Draw width should vary based on device size: +// http://www.google.com/design/spec/layout/structure.html#structure-side-nav + +// Mobile: +// Width = Screen width − 56 dp +// Maximum width: 320dp +// Maximum width applies only when using a left nav. When using a right nav, +// the panel can cover the full width of the screen. + +// Desktop/Tablet: +// Maximum width for a left nav is 400dp. +// The right nav can vary depending on content. + +const double _kWidth = 304.0; +const double _kMinFlingVelocity = 365.0; +const double _kFlingVelocityScale = 1.0 / 300.0; +const Duration _kBaseSettleDuration = const Duration(milliseconds: 246); +const Duration _kThemeChangeDuration = const Duration(milliseconds: 200); +const Point _kOpenPosition = Point.origin; +const Point _kClosedPosition = const Point(-_kWidth, 0.0); + +typedef void DrawerDismissedCallback(); + +class Drawer extends StatefulComponent { + Drawer({ + Key key, + this.children, + this.showing: false, + this.level: 0, + this.onDismissed, + this.navigator + }) : super(key: key); + + final List children; + final bool showing; + final int level; + final DrawerDismissedCallback onDismissed; + final NavigatorState navigator; + + DrawerState createState() => new DrawerState(this); +} + +class DrawerState extends ComponentState { + DrawerState(Drawer config) : super(config) { + _performance = new AnimationPerformance(duration: _kBaseSettleDuration); + _performance.addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.dismissed) + _handleDismissed(); + }); + // Use a spring force for animating the drawer. We can't use curves for + // this because we need a linear curve in order to track the user's finger + // while dragging. + _performance.attachedForce = kDefaultSpringForce; + if (config.navigator != null) { + // TODO(ianh): This is crazy. We should convert drawer to use a pattern like openDialog(). + // https://github.com/domokit/sky_engine/pull/1186 + scheduleMicrotask(() { + config.navigator.pushState(this, (_) => _performance.reverse()); + }); + } + _performance.play(_direction); + } + + AnimationPerformance _performance; + + Direction get _direction => config.showing ? Direction.forward : Direction.reverse; + + void didUpdateConfig(Drawer oldConfig) { + if (config.showing != oldConfig.showing) + _performance.play(_direction); + } + + Widget build(BuildContext context) { + var mask = new GestureDetector( + child: new ColorTransition( + performance: _performance.view, + color: new AnimatedColorValue(Colors.transparent, end: const Color(0x7F000000)), + child: new Container() + ), + onTap: () { + _performance.reverse(); + } + ); + + Widget content = new SlideTransition( + performance: _performance.view, + position: new AnimatedValue(_kClosedPosition, end: _kOpenPosition), + // TODO(abarth): Use AnimatedContainer + child: new Container( + // behavior: implicitlyAnimate(const Duration(milliseconds: 200)), + decoration: new BoxDecoration( + backgroundColor: Theme.of(context).canvasColor, + boxShadow: shadows[config.level]), + width: _kWidth, + child: new Block(config.children) + ) + ); + + return new GestureDetector( + onHorizontalDragStart: _performance.stop, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: new Stack([ mask, content ]) + ); + } + + void _handleDismissed() { + if (config.navigator != null && + config.navigator.currentRoute is RouteState && + (config.navigator.currentRoute as RouteState).owner == this) // TODO(ianh): remove cast once analyzer is cleverer + config.navigator.pop(); + if (config.onDismissed != null) + config.onDismissed(); + } + + bool get _isMostlyClosed => _performance.progress < 0.5; + + void _settle() { _isMostlyClosed ? _performance.reverse() : _performance.play(); } + + void _handleDragUpdate(double delta) { + _performance.progress += delta / _kWidth; + } + + void _handleDragEnd(Offset velocity) { + if (velocity.dx.abs() >= _kMinFlingVelocity) { + _performance.fling(velocity: velocity.dx * _kFlingVelocityScale); + } else { + _settle(); + } + } + +} diff --git a/packages/flutter/lib/src/fn3/navigator.dart b/packages/flutter/lib/src/fn3/navigator.dart index 19214301a0..c7c6ebc45b 100644 --- a/packages/flutter/lib/src/fn3/navigator.dart +++ b/packages/flutter/lib/src/fn3/navigator.dart @@ -90,7 +90,7 @@ class RouteState extends RouteBase { Function callback; RouteBase route; - StatefulComponent owner; + ComponentState owner; bool get isOpaque => false; @@ -160,7 +160,7 @@ class NavigatorState extends ComponentState { RouteBase get currentRoute => config.history.currentRoute; - void pushState(StatefulComponent owner, Function callback) { + void pushState(ComponentState owner, Function callback) { RouteBase route = new RouteState( owner: owner, callback: callback, diff --git a/packages/flutter/lib/src/fn3/snack_bar.dart b/packages/flutter/lib/src/fn3/snack_bar.dart new file mode 100644 index 0000000000..450a82470e --- /dev/null +++ b/packages/flutter/lib/src/fn3/snack_bar.dart @@ -0,0 +1,107 @@ +// Copyright 2015 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:sky/animation.dart'; +import 'package:sky/painting.dart'; +import 'package:sky/material.dart'; +import 'package:sky/src/fn3/animated_component.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/material.dart'; +import 'package:sky/src/fn3/theme.dart'; +import 'package:sky/src/fn3/transitions.dart'; + +typedef void SnackBarDismissedCallback(); + +const Duration _kSlideInDuration = const Duration(milliseconds: 200); +// TODO(ianh): factor out some of the constants below + +class SnackBarAction extends StatelessComponent { + SnackBarAction({Key key, this.label, this.onPressed }) : super(key: key) { + assert(label != null); + } + + final String label; + final Function onPressed; + + Widget build(BuildContext) { + return new GestureDetector( + onTap: onPressed, + child: new Container( + margin: const EdgeDims.only(left: 24.0), + padding: const EdgeDims.only(top: 14.0, bottom: 14.0), + child: new Text(label) + ) + ); + } +} + +class SnackBar extends AnimatedComponent { + SnackBar({ + Key key, + this.transitionKey, + this.content, + this.actions, + bool showing, + this.onDismissed + }) : super(key: key, direction: showing ? Direction.forward : Direction.reverse, duration: _kSlideInDuration) { + assert(content != null); + } + + final Key transitionKey; + final Widget content; + final List actions; + final SnackBarDismissedCallback onDismissed; + + SnackBarState createState() => new SnackBarState(this); +} + +class SnackBarState extends AnimatedComponentState { + SnackBarState(SnackBar config) : super(config); + + void handleDismissed() { + if (config.onDismissed != null) + config.onDismissed(); + } + + Widget build(BuildContext context) { + List children = [ + new Flexible( + child: new Container( + margin: const EdgeDims.symmetric(vertical: 14.0), + child: new DefaultTextStyle( + style: Typography.white.subhead, + child: config.content + ) + ) + ) + ]; + if (config.actions != null) + children.addAll(config.actions); + return new SlideTransition( + key: config.transitionKey, + performance: performance.view, + position: new AnimatedValue( + Point.origin, + end: const Point(0.0, -52.0), + curve: easeIn, + reverseCurve: easeOut + ), + child: new Material( + level: 2, + color: const Color(0xFF323232), + type: MaterialType.canvas, + child: new Container( + margin: const EdgeDims.symmetric(horizontal: 24.0), + child: new DefaultTextStyle( + style: new TextStyle(color: Theme.of(context).accentColor), + child: new Row(children) + ) + ) + ) + ); + } +} diff --git a/packages/flutter/lib/src/fn3/tabs.dart b/packages/flutter/lib/src/fn3/tabs.dart new file mode 100644 index 0000000000..b2c25ebe29 --- /dev/null +++ b/packages/flutter/lib/src/fn3/tabs.dart @@ -0,0 +1,620 @@ +// Copyright 2015 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 'dart:math' as math; +import 'dart:sky' as sky; + +import 'package:newton/newton.dart'; +import 'package:sky/animation.dart'; +import 'package:sky/painting.dart'; +import 'package:sky/rendering.dart'; +import 'package:sky/material.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/icon.dart'; +import 'package:sky/src/fn3/ink_well.dart'; +import 'package:sky/src/fn3/scrollable.dart'; +import 'package:sky/src/fn3/theme.dart'; +import 'package:sky/src/fn3/transitions.dart'; + +typedef void SelectedIndexChanged(int selectedIndex); +typedef void LayoutChanged(Size size, List widths); + +// See https://www.google.com/design/spec/components/tabs.html#tabs-specs +const double _kTabHeight = 46.0; +const double _kTextAndIconTabHeight = 72.0; +const double _kTabIndicatorHeight = 2.0; +const double _kMinTabWidth = 72.0; +const double _kMaxTabWidth = 264.0; +const EdgeDims _kTabLabelPadding = const EdgeDims.symmetric(horizontal: 12.0); +const int _kTabIconSize = 24; +const double _kTabBarScrollDrag = 0.025; +const Duration _kTabBarScroll = const Duration(milliseconds: 200); + +class TabBarParentData extends BoxParentData with + ContainerParentDataMixin { } + +class RenderTabBar extends RenderBox with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + + RenderTabBar(this.onLayoutChanged); + + int _selectedIndex; + int get selectedIndex => _selectedIndex; + void set selectedIndex(int value) { + if (_selectedIndex != value) { + _selectedIndex = value; + markNeedsPaint(); + } + } + + Color _backgroundColor; + Color get backgroundColor => _backgroundColor; + void set backgroundColor(Color value) { + if (_backgroundColor != value) { + _backgroundColor = value; + markNeedsPaint(); + } + } + + Color _indicatorColor; + Color get indicatorColor => _indicatorColor; + void set indicatorColor(Color value) { + if (_indicatorColor != value) { + _indicatorColor = value; + markNeedsPaint(); + } + } + + Rect _indicatorRect; + Rect get indicatorRect => _indicatorRect; + void set indicatorRect(Rect value) { + if (_indicatorRect != value) { + _indicatorRect = value; + markNeedsPaint(); + } + } + + bool _textAndIcons; + bool get textAndIcons => _textAndIcons; + void set textAndIcons(bool value) { + if (_textAndIcons != value) { + _textAndIcons = value; + markNeedsLayout(); + } + } + + bool _isScrollable; + bool get isScrollable => _isScrollable; + void set isScrollable(bool value) { + if (_isScrollable != value) { + _isScrollable = value; + markNeedsLayout(); + } + } + + void setupParentData(RenderBox child) { + if (child.parentData is! TabBarParentData) + child.parentData = new TabBarParentData(); + } + + double getMinIntrinsicWidth(BoxConstraints constraints) { + BoxConstraints widthConstraints = + new BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight); + + double maxWidth = 0.0; + RenderBox child = firstChild; + while (child != null) { + maxWidth = math.max(maxWidth, child.getMinIntrinsicWidth(widthConstraints)); + assert(child.parentData is TabBarParentData); + child = child.parentData.nextSibling; + } + double width = isScrollable ? maxWidth : maxWidth * childCount; + return constraints.constrainWidth(width); + } + + double getMaxIntrinsicWidth(BoxConstraints constraints) { + BoxConstraints widthConstraints = + new BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight); + + double maxWidth = 0.0; + RenderBox child = firstChild; + while (child != null) { + maxWidth = math.max(maxWidth, child.getMaxIntrinsicWidth(widthConstraints)); + assert(child.parentData is TabBarParentData); + child = child.parentData.nextSibling; + } + double width = isScrollable ? maxWidth : maxWidth * childCount; + return constraints.constrainWidth(width); + } + + double get _tabHeight => textAndIcons ? _kTextAndIconTabHeight : _kTabHeight; + double get _tabBarHeight => _tabHeight + _kTabIndicatorHeight; + + double _getIntrinsicHeight(BoxConstraints constraints) => constraints.constrainHeight(_tabBarHeight); + + double getMinIntrinsicHeight(BoxConstraints constraints) => _getIntrinsicHeight(constraints); + + double getMaxIntrinsicHeight(BoxConstraints constraints) => _getIntrinsicHeight(constraints); + + void layoutFixedWidthTabs() { + double tabWidth = size.width / childCount; + BoxConstraints tabConstraints = + new BoxConstraints.tightFor(width: tabWidth, height: _tabHeight); + double x = 0.0; + RenderBox child = firstChild; + while (child != null) { + child.layout(tabConstraints); + assert(child.parentData is TabBarParentData); + child.parentData.position = new Point(x, 0.0); + x += tabWidth; + child = child.parentData.nextSibling; + } + } + + double layoutScrollableTabs() { + BoxConstraints tabConstraints = new BoxConstraints( + minWidth: _kMinTabWidth, + maxWidth: _kMaxTabWidth, + minHeight: _tabHeight, + maxHeight: _tabHeight + ); + double x = 0.0; + RenderBox child = firstChild; + while (child != null) { + child.layout(tabConstraints, parentUsesSize: true); + assert(child.parentData is TabBarParentData); + child.parentData.position = new Point(x, 0.0); + x += child.size.width; + child = child.parentData.nextSibling; + } + return x; + } + + Size layoutSize; + List layoutWidths; + LayoutChanged onLayoutChanged; + + void reportLayoutChangedIfNeeded() { + assert(onLayoutChanged != null); + List widths = new List(childCount); + if (!isScrollable && childCount > 0) { + double tabWidth = size.width / childCount; + widths.fillRange(0, widths.length, tabWidth); + } else if (isScrollable) { + RenderBox child = firstChild; + int childIndex = 0; + while (child != null) { + widths[childIndex++] = child.size.width; + child = child.parentData.nextSibling; + } + assert(childIndex == widths.length); + } + if (size != layoutSize || widths != layoutWidths) { + layoutSize = size; + layoutWidths = widths; + onLayoutChanged(layoutSize, layoutWidths); + } + } + + void performLayout() { + assert(constraints is BoxConstraints); + if (childCount == 0) + return; + + if (isScrollable) { + double tabBarWidth = layoutScrollableTabs(); + size = constraints.constrain(new Size(tabBarWidth, _tabBarHeight)); + } else { + size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight)); + layoutFixedWidthTabs(); + } + + if (onLayoutChanged != null) + reportLayoutChangedIfNeeded(); + } + + void hitTestChildren(HitTestResult result, { Point position }) { + defaultHitTestChildren(result, position: position); + } + + void _paintIndicator(PaintingCanvas canvas, RenderBox selectedTab, Offset offset) { + if (indicatorColor == null) + return; + + if (indicatorRect != null) { + canvas.drawRect(indicatorRect.shift(offset), new Paint()..color = indicatorColor); + return; + } + + var size = new Size(selectedTab.size.width, _kTabIndicatorHeight); + var point = new Point( + selectedTab.parentData.position.x, + _tabBarHeight - _kTabIndicatorHeight + ); + Rect rect = (point + offset) & size; + canvas.drawRect(rect, new Paint()..color = indicatorColor); + } + + void paint(PaintingContext context, Offset offset) { + if (backgroundColor != null) { + double width = layoutWidths != null + ? layoutWidths.reduce((sum, width) => sum + width) + : size.width; + Rect rect = offset & new Size(width, size.height); + context.canvas.drawRect(rect, new Paint()..color = backgroundColor); + } + int index = 0; + RenderBox child = firstChild; + while (child != null) { + assert(child.parentData is TabBarParentData); + context.paintChild(child, child.parentData.position + offset); + if (index++ == selectedIndex) + _paintIndicator(context.canvas, child, offset); + child = child.parentData.nextSibling; + } + } +} + +class _TabBarWrapper extends MultiChildRenderObjectWidget { + _TabBarWrapper({ + Key key, + List children, + this.selectedIndex, + this.backgroundColor, + this.indicatorColor, + this.indicatorRect, + this.textAndIcons, + this.isScrollable: false, + this.onLayoutChanged + }) : super(key: key, children: children); + + final int selectedIndex; + final Color backgroundColor; + final Color indicatorColor; + final Rect indicatorRect; + final bool textAndIcons; + final bool isScrollable; + final LayoutChanged onLayoutChanged; + + RenderTabBar createRenderObject() { + RenderTabBar result = new RenderTabBar(onLayoutChanged); + updateRenderObject(result, null); + return result; + } + + void updateRenderObject(RenderTabBar renderObject, _TabBarWrapper oldWidget) { + renderObject.selectedIndex = selectedIndex; + renderObject.backgroundColor = backgroundColor; + renderObject.indicatorColor = indicatorColor; + renderObject.indicatorRect = indicatorRect; + renderObject.textAndIcons = textAndIcons; + renderObject.isScrollable = isScrollable; + renderObject.onLayoutChanged = onLayoutChanged; + } +} + +class TabLabel { + const TabLabel({ this.text, this.icon }); + + final String text; + final String icon; +} + +class Tab extends StatelessComponent { + Tab({ + Key key, + this.label, + this.color, + this.selected: false, + this.selectedColor + }) : super(key: key) { + assert(label.text != null || label.icon != null); + } + + final TabLabel label; + final Color color; + final bool selected; + final Color selectedColor; + + Widget _buildLabelText() { + assert(label.text != null); + TextStyle style = new TextStyle(color: selected ? selectedColor : color); + return new Text(label.text, style: style); + } + + Widget _buildLabelIcon() { + assert(label.icon != null); + Color iconColor = selected ? selectedColor : color; + sky.ColorFilter filter = new sky.ColorFilter.mode(iconColor, sky.TransferMode.srcATop); + return new Icon(type: label.icon, size: _kTabIconSize, colorFilter: filter); + } + + Widget build(BuildContext context) { + Widget labelContent; + if (label.icon == null) { + labelContent = _buildLabelText(); + } else if (label.text == null) { + labelContent = _buildLabelIcon(); + } else { + labelContent = new Column( + [ + new Container( + child: _buildLabelIcon(), + margin: const EdgeDims.only(bottom: 10.0) + ), + _buildLabelText() + ], + justifyContent: FlexJustifyContent.center, + alignItems: FlexAlignItems.center + ); + } + + Container centeredLabel = new Container( + child: new Center(child: labelContent), + constraints: new BoxConstraints(minWidth: _kMinTabWidth), + padding: _kTabLabelPadding + ); + + return new InkWell(child: centeredLabel); + } +} + +class _TabsScrollBehavior extends BoundedBehavior { + _TabsScrollBehavior(); + + bool isScrollable = true; + + Simulation release(double position, double velocity) { + if (!isScrollable) + return null; + + double velocityPerSecond = velocity * 1000.0; + return new BoundedFrictionSimulation( + _kTabBarScrollDrag, position, velocityPerSecond, minScrollOffset, maxScrollOffset + ); + } + + double applyCurve(double scrollOffset, double scrollDelta) { + return (isScrollable) ? super.applyCurve(scrollOffset, scrollDelta) : 0.0; + } +} + +class TabBar extends Scrollable { + TabBar({ + Key key, + this.labels, + this.selectedIndex: 0, + this.onChanged, + this.isScrollable: false + }) : super(key: key, scrollDirection: ScrollDirection.horizontal); + + final Iterable labels; + final int selectedIndex; + final SelectedIndexChanged onChanged; + final bool isScrollable; + + TabBarState createState() => new TabBarState(this); +} + +class TabBarState extends ScrollableState { + TabBarState(TabBar config) : super(config) { + _indicatorAnimation = new ValueAnimation() + ..duration = _kTabBarScroll + ..variable = new AnimatedRect(null, curve: ease); + scrollBehavior.isScrollable = config.isScrollable; + } + + Size _tabBarSize; + Size _viewportSize = Size.zero; + List _tabWidths; + ValueAnimation _indicatorAnimation; + + void didUpdateConfig(TabBar oldConfig) { + super.didUpdateConfig(oldConfig); + if (!config.isScrollable) + scrollTo(0.0); + } + + AnimatedRect get _indicatorRect => _indicatorAnimation.variable; + + void _startIndicatorAnimation(int fromTabIndex, int toTabIndex) { + _indicatorRect + ..begin = (_indicatorRect.value == null ? _tabIndicatorRect(fromTabIndex) : _indicatorRect.value) + ..end = _tabIndicatorRect(toTabIndex); + _indicatorAnimation + ..progress = 0.0 + ..play(); + } + + ScrollBehavior createScrollBehavior() => new _TabsScrollBehavior(); + _TabsScrollBehavior get scrollBehavior => super.scrollBehavior; + + Rect _tabRect(int tabIndex) { + assert(_tabBarSize != null); + assert(_tabWidths != null); + assert(tabIndex >= 0 && tabIndex < _tabWidths.length); + double tabLeft = 0.0; + if (tabIndex > 0) + tabLeft = _tabWidths.take(tabIndex).reduce((sum, width) => sum + width); + double tabTop = 0.0; + double tabBottom = _tabBarSize.height - _kTabIndicatorHeight; + double tabRight = tabLeft + _tabWidths[tabIndex]; + return new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + } + + Rect _tabIndicatorRect(int tabIndex) { + Rect r = _tabRect(tabIndex); + return new Rect.fromLTRB(r.left, r.bottom, r.right, r.bottom + _kTabIndicatorHeight); + } + + double _centeredTabScrollOffset(int tabIndex) { + double viewportWidth = scrollBehavior.containerExtent; + return (_tabRect(tabIndex).left + _tabWidths[tabIndex] / 2.0 - viewportWidth / 2.0) + .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); + } + + void _handleTap(int tabIndex) { + if (tabIndex != config.selectedIndex) { + if (_tabWidths != null) { + if (config.isScrollable) + scrollTo(_centeredTabScrollOffset(tabIndex), duration: _kTabBarScroll); + _startIndicatorAnimation(config.selectedIndex, tabIndex); + } + if (config.onChanged != null) + config.onChanged(tabIndex); + } + } + + Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) { + return new GestureDetector( + onTap: () => _handleTap(tabIndex), + child: new Tab( + label: label, + color: color, + selected: tabIndex == config.selectedIndex, + selectedColor: selectedColor + ) + ); + } + + void _updateScrollBehavior() { + scrollBehavior.updateExtents( + containerExtent: config.scrollDirection == ScrollDirection.vertical ? _viewportSize.height : _viewportSize.width, + contentExtent: _tabWidths.reduce((sum, width) => sum + width) + ); + } + + void _layoutChanged(Size tabBarSize, List tabWidths) { + setState(() { + _tabBarSize = tabBarSize; + _tabWidths = tabWidths; + _updateScrollBehavior(); + }); + } + + void _handleViewportSizeChanged(Size newSize) { + _viewportSize = newSize; + _updateScrollBehavior(); + } + + Widget buildContent(BuildContext context) { + assert(config.labels != null && config.labels.isNotEmpty); + + ThemeData themeData = Theme.of(context); + Color backgroundColor = themeData.primaryColor; + Color indicatorColor = themeData.accentColor; + if (indicatorColor == backgroundColor) { + indicatorColor = Colors.white; + } + + TextStyle textStyle; + IconThemeColor iconThemeColor; + switch (themeData.primaryColorBrightness) { + case ThemeBrightness.light: + textStyle = Typography.black.body1; + iconThemeColor = IconThemeColor.black; + break; + case ThemeBrightness.dark: + textStyle = Typography.white.body1; + iconThemeColor = IconThemeColor.white; + break; + } + + List tabs = []; + bool textAndIcons = false; + int tabIndex = 0; + for (TabLabel label in config.labels) { + tabs.add(_toTab(label, tabIndex++, textStyle.color, indicatorColor)); + if (label.text != null && label.icon != null) + textAndIcons = true; + } + + Widget tabBar = new IconTheme( + data: new IconThemeData(color: iconThemeColor), + child: new DefaultTextStyle( + style: textStyle, + child: new BuilderTransition( + variables: [_indicatorRect], + performance: _indicatorAnimation.view, + builder: (BuildContext context) { + return new _TabBarWrapper( + children: tabs, + selectedIndex: config.selectedIndex, + backgroundColor: backgroundColor, + indicatorColor: indicatorColor, + indicatorRect: _indicatorRect.value, + textAndIcons: textAndIcons, + isScrollable: config.isScrollable, + onLayoutChanged: _layoutChanged + ); + } + ) + ) + ); + + if (!config.isScrollable) + return tabBar; + + return new SizeObserver( + callback: _handleViewportSizeChanged, + child: new Viewport( + scrollDirection: ScrollDirection.horizontal, + scrollOffset: new Offset(scrollOffset, 0.0), + child: tabBar + ) + ); + } +} + +class TabNavigatorView { + TabNavigatorView({ this.label, this.builder }); + + final TabLabel label; + final WidgetBuilder builder; + + Widget buildContent(BuildContext context) { + assert(builder != null); + Widget content = builder(context); + assert(content != null); + return content; + } +} + +class TabNavigator extends StatelessComponent { + TabNavigator({ + Key key, + this.views, + this.selectedIndex: 0, + this.onChanged, + this.isScrollable: false + }) : super(key: key); + + final List views; + final int selectedIndex; + final SelectedIndexChanged onChanged; + final bool isScrollable; + + void _handleSelectedIndexChanged(int tabIndex) { + if (onChanged != null) + onChanged(tabIndex); + } + + Widget build(BuildContext context) { + assert(views != null && views.isNotEmpty); + assert(selectedIndex >= 0 && selectedIndex < views.length); + + TabBar tabBar = new TabBar( + labels: views.map((view) => view.label), + onChanged: _handleSelectedIndexChanged, + selectedIndex: selectedIndex, + isScrollable: isScrollable + ); + + Widget content = views[selectedIndex].buildContent(context); + return new Column([tabBar, new Flexible(child: content)]); + } +}