Commit 9f176fc8 authored by Adam Barth's avatar Adam Barth

Port widgets that depend on scrolling to fn3

parent 042f49a7
// 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<DatePicker> {
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<Text> headers = [];
for (String weekDay in symbols.NARROWWEEKDAYS) {
headers.add(new Text(weekDay, style: headerStyle));
}
List<Widget> 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<int> days = [
DateTime.SUNDAY,
DateTime.MONDAY,
DateTime.TUESDAY,
DateTime.WEDNESDAY,
DateTime.THURSDAY,
DateTime.FRIDAY,
DateTime.SATURDAY
];
int daySlots = weeksShown * days.length;
List<Widget> 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<MonthPicker> {
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<Widget> buildItems(BuildContext context, int start, int count) {
List<Widget> result = new List<Widget>();
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<YearPicker> {
YearPickerState(YearPicker config) : super(config);
int get itemCount => config.lastDate.year - config.firstDate.year + 1;
List<Widget> buildItems(BuildContext context, int start, int count) {
TextStyle style = Theme.of(context).text.body1.copyWith(color: Colors.black54);
List<Widget> items = new List<Widget>();
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;
}
}
// 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
///
/// <https://www.google.com/design/spec/components/dialogs.html>
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<Widget> 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<Widget> dialogBody = new List<Widget>();
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<double>(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;
}
// 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<Widget> children;
final bool showing;
final int level;
final DrawerDismissedCallback onDismissed;
final NavigatorState navigator;
DrawerState createState() => new DrawerState(this);
}
class DrawerState extends ComponentState<Drawer> {
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<Point>(_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();
}
}
}
......@@ -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<Navigator> {
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,
......
// 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<SnackBarAction> actions;
final SnackBarDismissedCallback onDismissed;
SnackBarState createState() => new SnackBarState(this);
}
class SnackBarState extends AnimatedComponentState<SnackBar> {
SnackBarState(SnackBar config) : super(config);
void handleDismissed() {
if (config.onDismissed != null)
config.onDismissed();
}
Widget build(BuildContext context) {
List<Widget> 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>(
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)
)
)
)
);
}
}
// 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<double> 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<RenderBox> { }
class RenderTabBar extends RenderBox with
ContainerRenderObjectMixin<RenderBox, TabBarParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, TabBarParentData> {
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<double> layoutWidths;
LayoutChanged onLayoutChanged;
void reportLayoutChangedIfNeeded() {
assert(onLayoutChanged != null);
List<double> widths = new List<double>(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<Widget> 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(
<Widget>[
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<TabLabel> labels;
final int selectedIndex;
final SelectedIndexChanged onChanged;
final bool isScrollable;
TabBarState createState() => new TabBarState(this);
}
class TabBarState extends ScrollableState<TabBar> {
TabBarState(TabBar config) : super(config) {
_indicatorAnimation = new ValueAnimation<Rect>()
..duration = _kTabBarScroll
..variable = new AnimatedRect(null, curve: ease);
scrollBehavior.isScrollable = config.isScrollable;
}
Size _tabBarSize;
Size _viewportSize = Size.zero;
List<double> _tabWidths;
ValueAnimation<Rect> _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<double> 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<Widget> tabs = <Widget>[];
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<TabNavigatorView> 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)]);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment