blob: e04c244007e182bd1581fd295bccfb21264be78c [file] [log] [blame]
// Copyright 2019 The Fuchsia 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';
import 'package:internationalization/strings.dart';
import '../blocs/tabs_bloc.dart';
import '../blocs/webpage_bloc.dart';
import '../models/tabs_action.dart';
// TODO(fxb/45264): Make the common factors as part of Ermine central styles.
const _kTabBarHeight = 24.0;
const _kMinTabWidth = 120.0;
const _kBorderWidth = 1.0;
const _kTabPadding = EdgeInsets.symmetric(horizontal: _kTabBarHeight);
const _kScrollToMargin = _kMinTabWidth / 3;
const _kScrollAnimationDuration = 300;
const _kAutoScrollOffset = 5.0;
const _kIconSize = 14.0;
enum _ScrollDirection { left, right }
@visibleForTesting
double get kTabBarHeight => _kTabBarHeight;
@visibleForTesting
double get kMinTabWidth => _kMinTabWidth;
@visibleForTesting
double get kBorderWidth => _kBorderWidth;
@visibleForTesting
double get kScrollToMargin => _kScrollToMargin;
/// The list of currently opened tabs in the browser.
///
/// Builds different widget trees for the tab list depending on the selected tab
/// and the number of tabs.
/// Handles tab rearrangement and list scroll events, and is also involved in drawing
/// tab borders as they are affected by the tab rearrangement action.
class TabsWidget extends StatefulWidget {
final TabsBloc bloc;
const TabsWidget({@required this.bloc});
@override
_TabsWidgetState createState() => _TabsWidgetState();
}
class _TabsWidgetState extends State<TabsWidget>
with TickerProviderStateMixin<TabsWidget> {
double _tabListWidth = 0.0;
double _tabWidth = 0.0;
double _dragCursorOffset = 0.0;
final _currentTabX = ValueNotifier<double>(0.0);
int _ghostIndex = 0;
int _dragStartIndex = 0;
bool _isDragging;
bool _isAnimating;
static const Duration _reorderAnimationDuration = Duration(milliseconds: 200);
AnimationController _ghostController;
AnimationController _leftNewGhostController;
AnimationController _rightNewGhostController;
final _scrollController = ScrollController();
final _leftScrollButton = _ScrollButton(_ScrollDirection.left);
final _rightScrollButton = _ScrollButton(_ScrollDirection.right);
ThemeData _browserTheme;
@override
void initState() {
super.initState();
_isDragging = false;
_isAnimating = false;
_ghostController =
AnimationController(vsync: this, duration: _reorderAnimationDuration);
_leftNewGhostController =
AnimationController(vsync: this, duration: _reorderAnimationDuration);
_rightNewGhostController =
AnimationController(vsync: this, duration: _reorderAnimationDuration);
_ghostController.value = 1.0;
_leftNewGhostController.value = 0.0;
_rightNewGhostController.value = 0.0;
_currentTabX.addListener(_onCurrentTabXChanged);
_leftNewGhostController.addStatusListener(_onLeftNewGhostControllerChanged);
_rightNewGhostController
.addStatusListener(_onRightNewGhostControllerChanged);
_setupBloc(null, widget);
}
@override
void dispose() {
_setupBloc(widget, null);
_ghostController.dispose();
_leftNewGhostController
..removeStatusListener(_onLeftNewGhostControllerChanged)
..dispose();
_rightNewGhostController
..removeStatusListener(_onRightNewGhostControllerChanged)
..dispose();
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(TabsWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_setupBloc(oldWidget, widget);
}
void _setupBloc(TabsWidget oldWidget, TabsWidget newWidget) {
if (oldWidget?.bloc != newWidget?.bloc) {
oldWidget?.bloc?.currentTabNotifier?.removeListener(_onCurrentTabChanged);
widget?.bloc?.currentTabNotifier?.addListener(_onCurrentTabChanged);
oldWidget?.bloc?.tabsNotifier?.removeListener(_onTabsChanged);
widget?.bloc?.tabsNotifier?.addListener(_onTabsChanged);
}
}
@override
Widget build(BuildContext context) {
_tabListWidth = MediaQuery.of(context).size.width;
_browserTheme = Theme.of(context);
return AnimatedBuilder(
animation: Listenable.merge(
[widget.bloc.tabsNotifier, widget.bloc.currentTabNotifier]),
builder: (_, __) {
if (widget.bloc.tabs.length > 1) {
_setVariableTabWidth();
return Container(
height: _kTabBarHeight,
decoration: BoxDecoration(
color: _browserTheme.primaryColor,
border: Border(
top: BorderSide(
color: _browserTheme.accentColor,
width: _kBorderWidth,
),
bottom: BorderSide(
color: _browserTheme.accentColor,
width: _kBorderWidth,
),
),
),
child: LayoutBuilder(
builder: (context, constraints) => _tabWidth > _kMinTabWidth
? _buildTabStacks()
: _buildScrollableTabListWithButtons(),
),
);
}
return Offstage();
});
}
void _setVariableTabWidth() {
_tabWidth = (_tabListWidth / widget.bloc.tabs.length)
.clamp(_kMinTabWidth, _tabListWidth / 2);
if (!_isDragging) {
_currentTabX.value = _tabWidth * widget.bloc.currentTabIdx;
}
}
// BUILDERS
Widget _buildScrollableTabListWithButtons() => Row(
children: <Widget>[
_buildScrollButton(_leftScrollButton),
Expanded(child: _buildScrollableTabList()),
_buildScrollButton(_rightScrollButton),
],
);
Widget _buildScrollableTabList() => NotificationListener<ScrollNotification>(
onNotification: (scrollNotification) {
if (scrollNotification is ScrollEndNotification) {
_onScrollEnd(scrollNotification.metrics);
}
return true;
},
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
physics: NeverScrollableScrollPhysics(),
child: _buildTabStacks(),
),
);
Widget _buildScrollButton(_ScrollButton button) => GestureDetector(
onTap: () => _onScrollButtonTap(button),
child: Container(
width: _kTabBarHeight,
height: _kTabBarHeight,
color: _browserTheme.buttonColor,
child: Center(
child: AnimatedBuilder(
animation: button.isEnabled,
builder: (_, __) => Icon(
button.icon,
color: button.isEnabled.value
? _browserTheme.primaryColor
: _browserTheme.primaryColor.withOpacity(0.2),
size: _kIconSize,
),
),
),
),
);
Widget _buildTabStacks() => Stack(
children: <Widget>[
Row(
children: List.generate(widget.bloc.tabs.length, (index) {
return _wrapWithGestureDetector(
index,
_buildUnselectedTab(index),
);
}),
),
AnimatedBuilder(
animation: _currentTabX,
builder: (_, __) => Positioned(
left: _currentTabX.value,
child: _wrapWithGestureDetector(
widget.bloc.currentTabIdx,
_buildTabWithBorder(widget.bloc.currentTabIdx),
),
),
),
],
);
// Makes a tab to be rearrangeable and selectable.
Widget _wrapWithGestureDetector(int index, Widget child) => GestureDetector(
onHorizontalDragStart: (DragStartDetails details) =>
_onDragStart(index, details),
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: child,
);
Widget _buildUnselectedTab(int index) {
final spacing = Container(
width: _tabWidth,
height: _kTabBarHeight,
decoration: BoxDecoration(
border: _buildBorder(index != 0),
),
);
if (index == _ghostIndex) {
return _buildGhostTab(_ghostController, spacing);
}
int actualIndex = index;
// Shifts the tabs located between the moving tab's original and current positions
// to the right if the it is moving to the left.
if (_ghostIndex < widget.bloc.currentTabIdx) {
//
if (index > _ghostIndex && index <= widget.bloc.currentTabIdx) {
actualIndex = index - 1;
}
}
// Shifts the tabs located between the moving tab's original and current positions
// to the left if it is moving to the right.
else if (_ghostIndex > widget.bloc.currentTabIdx) {
if (index < _ghostIndex && index >= widget.bloc.currentTabIdx) {
actualIndex = index + 1;
}
}
final child = _buildTabWithBorder(actualIndex, renderingIndex: index);
// Inserts a potential empty space to the left of the tab which is currently left
// to the moving tab.
if (index == _ghostIndex - 1) {
return Row(
children: [
_buildGhostTab(_leftNewGhostController, spacing),
child,
],
);
}
// Inserts a potential empty space to the right of the tab which is currently right
// to the moving tab.
else if (index == _ghostIndex + 1) {
return Row(
children: [
child,
_buildGhostTab(_rightNewGhostController, spacing),
],
);
}
return child;
}
Widget _buildTabWithBorder(int index, {int renderingIndex}) {
renderingIndex ??= index;
return Container(
key: Key('tab'),
width: _tabWidth,
height: _kTabBarHeight,
decoration: BoxDecoration(
color: (index == widget.bloc.currentTabIdx)
? _browserTheme.accentColor
: _browserTheme.primaryColor,
border: _buildBorder(index != widget.bloc.currentTabIdx &&
!((!_isAnimating) && renderingIndex == 0) &&
!(_isAnimating &&
(renderingIndex != _ghostIndex - 1 && renderingIndex == 0))),
),
child: _buildTab(widget.bloc.tabs[index]),
);
}
// Creates an empty space for the selected tab on the unselected tab widget list.
Widget _buildGhostTab(
AnimationController animationController, Widget child) =>
SizeTransition(
sizeFactor: animationController,
axis: Axis.horizontal,
axisAlignment: -1.0,
child: child,
);
Border _buildBorder(bool hasBorder) => Border(
left: BorderSide(
color: hasBorder ? _browserTheme.accentColor : Colors.transparent,
width: _kBorderWidth,
),
);
Widget _buildTab(WebPageBloc tab) => _TabWidget(
bloc: tab,
selected: tab == widget.bloc.currentTab,
onSelect: () => widget.bloc.request.add(FocusTabAction(tab: tab)),
onClose: () => widget.bloc.request.add(RemoveTabAction(tab: tab)),
);
// EVENT HANDLERS
void _onTabsChanged() {
_syncGhost();
}
void _onCurrentTabChanged() {
_syncGhost();
if (_scrollController.hasClients) {
final viewportWidth = _scrollController.position.viewportDimension;
final offsetForLeftEdge = _currentTabX.value - _kScrollToMargin;
final offsetForRightEdge =
_currentTabX.value - viewportWidth + _kMinTabWidth + _kScrollToMargin;
double newOffset;
if (_scrollController.offset > offsetForLeftEdge) {
newOffset = offsetForLeftEdge;
} else if (_scrollController.offset < offsetForRightEdge) {
newOffset = offsetForRightEdge;
}
if (newOffset != null) {
_scrollController.animateTo(
newOffset,
duration: Duration(milliseconds: _kScrollAnimationDuration),
curve: Curves.ease,
);
}
}
}
void _syncGhost() {
_ghostIndex = widget.bloc.currentTabIdx;
_currentTabX.value = _tabWidth * _ghostIndex;
}
// Remembers the current values of properties such as the dragging target tab's
// position and the scroll controller's offset when dragging starts.
void _onDragStart(int index, DragStartDetails details) {
if (index != widget.bloc.currentTabIdx) {
widget.bloc.request.add(FocusTabAction(tab: widget.bloc.tabs[index]));
}
_isDragging = true;
_dragStartIndex = index;
_ghostIndex = index;
double scrollOffset = 0.0;
if (_scrollController.hasClients) {
scrollOffset = _scrollController.offset;
}
// The distance between the mouse cursor and the moving tab's left edge
// on the X-axis.
_dragCursorOffset = (_tabWidth * _dragStartIndex - scrollOffset) -
details.globalPosition.dx;
}
// Updates the moving tab's position as the mouse cursor moves.
void _onDragUpdate(DragUpdateDetails details) {
double scrollOffset = 0.0;
double dragXMax = _tabListWidth - _tabWidth;
if (_scrollController.hasClients) {
scrollOffset = _scrollController.offset;
dragXMax = (_kMinTabWidth * widget.bloc.tabs.length) - _kMinTabWidth;
}
double newX = details.globalPosition.dx + _dragCursorOffset + scrollOffset;
_currentTabX.value = newX.clamp(0.0, dragXMax);
if (_scrollController.hasClients) {
if (_didHitLeftEdge()) {
_autoScrollTo(_ScrollDirection.left);
} else if (_didHitRightEdge()) {
_autoScrollTo(_ScrollDirection.right);
}
}
}
void _onDragEnd(DragEndDetails details) {
_isDragging = false;
// Rearranges the selected tab to the currently empty space only when
// there is no unfinished animations.
if (!_isAnimating) {
_completeRearrangement();
}
}
void _onLeftNewGhostControllerChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
_isAnimating = false;
--_ghostIndex;
_ghostController.value = 1.0;
_leftNewGhostController.value = 0.0;
_onAnimationInterrupted();
}
}
void _onCurrentTabXChanged() {
if (_isDragging) {
// Sees if the moving tab is overlapping more than half of its neighbor tab,
// then shift the overlapped tab to the left/right accordingly.
// Does not check while a shifting animation is happening.
if (!_isAnimating) {
if (_isOverlappingLeftTabHalf()) {
_shiftLeftToRight();
}
if (_isOverlappingRightTabHalf()) {
_shiftRightToLeft();
}
}
}
}
void _onRightNewGhostControllerChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
_isAnimating = false;
++_ghostIndex;
_ghostController.value = 1.0;
_rightNewGhostController.value = 0.0;
_onAnimationInterrupted();
}
}
// Checks if the DragEnd event occurs before a spacing;s size transition animation
// finishes, and if so, rearranges the selected tab to the currently empty space.
void _onAnimationInterrupted() {
if (_isDragging == false) {
_completeRearrangement();
}
}
// Changes the order of the tabs in the TabsBloc.
void _completeRearrangement() {
if (_ghostIndex != _dragStartIndex) {
widget.bloc.request.add(RearrangeTabsAction(
originalIndex: _dragStartIndex,
newIndex: _ghostIndex,
));
}
_currentTabX.value = _tabWidth * _ghostIndex;
}
void _onScrollButtonTap(_ScrollButton button) {
if (!button.isEnabled.value) {
return;
}
final currentOffset = _scrollController.offset;
final newOffset = (_tabListWidth / 2) * button.directionFactor;
_scrollController.animateTo(
currentOffset + newOffset,
duration: Duration(milliseconds: _kScrollAnimationDuration),
curve: Curves.ease,
);
}
void _onScrollEnd(ScrollMetrics metrics) {
if (_canScrollTo(_ScrollDirection.left)) {
_leftScrollButton.enable();
} else {
_leftScrollButton.disable();
}
if (_canScrollTo(_ScrollDirection.right)) {
_rightScrollButton.enable();
} else {
_rightScrollButton.disable();
}
}
// CHECKERS
bool _didHitLeftEdge() {
final scrollOffset = _scrollController.offset;
return (_currentTabX.value < scrollOffset);
}
bool _didHitRightEdge() {
final scrollOffset = _scrollController.offset;
final listViewportWidth = _scrollController.position.viewportDimension;
return (_currentTabX.value + _kMinTabWidth >
scrollOffset + listViewportWidth);
}
bool _isOverlappingLeftTabHalf() {
if (_ghostIndex < 1) {
return false;
}
double leftTabCenterX = _tabWidth * (_ghostIndex - 1) + (_tabWidth / 2);
if (_currentTabX.value < leftTabCenterX) {
return true;
}
return false;
}
bool _isOverlappingRightTabHalf() {
if (_ghostIndex > widget.bloc.tabs.length - 2) {
return false;
}
double rightTabCenterX = _tabWidth * (_ghostIndex + 1) + (_tabWidth / 2);
if (_currentTabX.value + _tabWidth > rightTabCenterX) {
return true;
}
return false;
}
bool _canScrollTo(_ScrollDirection direction) {
switch (direction) {
case _ScrollDirection.left:
if (_scrollController.offset <= 0.0) {
return false;
}
return true;
case _ScrollDirection.right:
if (_scrollController.offset >=
(_kMinTabWidth * widget.bloc.tabs.length -
_scrollController.position.viewportDimension)) {
return false;
}
return true;
default:
return true;
}
}
// ANIMATORS
void _autoScrollTo(_ScrollDirection direction) {
final currentOffset = _scrollController.offset;
final addOnOffset = (direction == _ScrollDirection.left)
? _kAutoScrollOffset * -1
: _kAutoScrollOffset;
final offsetMax = (_tabWidth * widget.bloc.tabs.length) -
_scrollController.position.viewportDimension;
final newOffset = (currentOffset + addOnOffset).clamp(0.0, offsetMax);
final tabXMax = (_tabWidth * widget.bloc.tabs.length) - _tabWidth;
_scrollController.jumpTo(newOffset);
_currentTabX.value = (_currentTabX.value + addOnOffset).clamp(0.0, tabXMax);
}
void _shiftLeftToRight() {
_isAnimating = true;
_ghostController.reverse(from: 1.0);
_leftNewGhostController.forward(from: 0.0);
}
void _shiftRightToLeft() {
_isAnimating = true;
_ghostController.reverse(from: 1.0);
_rightNewGhostController.forward(from: 0.0);
}
}
/// An individual tab.
///
/// Shows the title of its webpage and displays a'close' button when it is selected or
/// a mouse cursor hovers over it. Also, handles the closing tab event when the close
/// button is tapped on.
class _TabWidget extends StatefulWidget {
const _TabWidget({this.bloc, this.selected, this.onSelect, this.onClose});
final WebPageBloc bloc;
final bool selected;
final VoidCallback onSelect;
final VoidCallback onClose;
@override
_TabWidgetState createState() => _TabWidgetState();
}
class _TabWidgetState extends State<_TabWidget> {
final _hovering = ValueNotifier<bool>(false);
@override
Widget build(BuildContext context) {
final baseTheme = Theme.of(context);
return MouseRegion(
onEnter: (_) {
_hovering.value = true;
},
onExit: (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_hovering.value = false;
});
},
child: DefaultTextStyle(
style: baseTheme.textTheme.bodyText2.copyWith(
color:
widget.selected ? baseTheme.primaryColor : baseTheme.accentColor,
),
child: Stack(
children: <Widget>[
Center(
child: Padding(
padding: _kTabPadding,
child: AnimatedBuilder(
animation: widget.bloc.pageTitleNotifier,
builder: (_, __) => Text(
widget.bloc.pageTitle ?? Strings.newtab.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
Positioned(
right: 0,
child: AnimatedBuilder(
animation: _hovering,
builder: (_, child) => Offstage(
offstage: !(widget.selected || _hovering.value),
child: child,
),
child: Padding(
padding: EdgeInsets.all(4),
child: GestureDetector(
onTap: widget.onClose,
child: Container(
color: Colors.transparent,
alignment: Alignment.center,
child: Icon(
Icons.clear,
color: widget.selected
? baseTheme.primaryColor
: baseTheme.accentColor,
size: _kIconSize,
),
),
),
),
),
),
],
),
),
);
}
}
class _ScrollButton {
final _ScrollDirection type;
IconData icon;
final ValueNotifier<bool> isEnabled = ValueNotifier<bool>(true);
double directionFactor = 0.0;
_ScrollButton(this.type)
: assert(
type == _ScrollDirection.left || type == _ScrollDirection.right) {
switch (type) {
case _ScrollDirection.left:
icon = Icons.keyboard_arrow_left;
directionFactor = -1.0;
break;
case _ScrollDirection.right:
icon = Icons.keyboard_arrow_right;
directionFactor = 1.0;
break;
}
}
void disable() => isEnabled.value = false;
void enable() => isEnabled.value = true;
}