[simple_browser] Tab Rearrangement (3/4)

- Changed the UI and the behavior of the scrollable tab list with scroll buttons  and enabled the tab rearrangement feature on it (fxb/45238)
- Modified existing widget tests for the scrollable tab list to fit the changed widget structure.
- Added new widget tests for the scroll buttons.
- Changed the color theme to meet the design spec.

Change-Id: I3686459fa7b071356ca33f6b794611b061522150
diff --git a/bin/simple_browser/lib/app.dart b/bin/simple_browser/lib/app.dart
index 9ac693b..36d20d1 100644
--- a/bin/simple_browser/lib/app.dart
+++ b/bin/simple_browser/lib/app.dart
@@ -19,10 +19,13 @@
 import 'src/widgets/navigation_bar.dart';
 import 'src/widgets/tabs_widget.dart';
 
-const _kBackgroundColor = Color(0xFFE5E5E5);
-const _kForegroundColor = Color(0xFF191919);
-const _kSelectionColor = Color(0x26191919);
-const _kTextStyle = TextStyle(color: _kForegroundColor, fontSize: 14.0);
+// TODO(fxb/45264): Replace these with colors from the central Ermine styles.
+const _kErmineColor100 = Color(0xFFE5E5E5);
+const _kErmineColor200 = Color(0xFFBDBDBD);
+const _kErmineColor300 = Color(0xFF282828);
+const _kErmineColor400 = Color(0xFF0C0C0C);
+
+const _kTextStyle = TextStyle(color: _kErmineColor400, fontSize: 14.0);
 
 class App extends StatelessWidget {
   final AppModel model;
@@ -44,13 +47,14 @@
                   title: Strings.browser,
                   theme: ThemeData(
                     fontFamily: 'RobotoMono',
-                    textSelectionColor: _kSelectionColor,
-                    textSelectionHandleColor: _kForegroundColor,
-                    hintColor: _kForegroundColor,
-                    cursorColor: _kForegroundColor,
-                    primaryColor: _kBackgroundColor,
-                    canvasColor: _kBackgroundColor,
-                    accentColor: _kForegroundColor,
+                    textSelectionColor: _kErmineColor200,
+                    textSelectionHandleColor: _kErmineColor400,
+                    hintColor: _kErmineColor400,
+                    cursorColor: _kErmineColor400,
+                    primaryColor: _kErmineColor100,
+                    canvasColor: _kErmineColor100,
+                    accentColor: _kErmineColor400,
+                    buttonColor: _kErmineColor300,
                     textTheme: TextTheme(
                       bodyText2: _kTextStyle,
                       subtitle1: _kTextStyle,
diff --git a/bin/simple_browser/lib/src/widgets/tabs_widget.dart b/bin/simple_browser/lib/src/widgets/tabs_widget.dart
index 7f470c2..22ac395 100644
--- a/bin/simple_browser/lib/src/widgets/tabs_widget.dart
+++ b/bin/simple_browser/lib/src/widgets/tabs_widget.dart
@@ -1,18 +1,23 @@
 // 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 _kSeparatorWidth = 1.0;
+const _kBorderWidth = 1.0;
 const _kTabPadding = EdgeInsets.symmetric(horizontal: _kTabBarHeight);
 const _kScrollToMargin = _kMinTabWidth / 3;
-const _kCloseMark = '×';
+const _kScrollAnimationDuration = 300;
+const _kIconSize = 14.0;
+
+enum _ScrollButtonType { left, right }
 
 @visibleForTesting
 double get kTabBarHeight => _kTabBarHeight;
@@ -21,14 +26,11 @@
 double get kMinTabWidth => _kMinTabWidth;
 
 @visibleForTesting
-double get kSeparatorWidth => _kSeparatorWidth;
+double get kBorderWidth => _kBorderWidth;
 
 @visibleForTesting
 double get kScrollToMargin => _kScrollToMargin;
 
-@visibleForTesting
-String get kCloseMark => _kCloseMark;
-
 /// The list of currently opened tabs in the browser.
 ///
 /// Builds different widget trees for the tab list depending on the selected tab
@@ -59,7 +61,10 @@
   AnimationController _ghostController;
   AnimationController _leftNewGhostController;
   AnimationController _rightNewGhostController;
+
   final _scrollController = ScrollController();
+  final _leftScrollButton = _ScrollButton(_ScrollButtonType.left);
+  final _rightScrollButton = _ScrollButton(_ScrollButtonType.right);
 
   ThemeData _browserTheme;
 
@@ -133,24 +138,18 @@
                 border: Border(
                   top: BorderSide(
                     color: _browserTheme.accentColor,
-                    width: 1.0,
+                    width: _kBorderWidth,
                   ),
                   bottom: BorderSide(
                     color: _browserTheme.accentColor,
-                    width: 1.0,
+                    width: _kBorderWidth,
                   ),
                 ),
               ),
               child: LayoutBuilder(
                 builder: (context, constraints) => _tabWidth > _kMinTabWidth
                     ? _buildTabStacks()
-                    // TODO (fxb/45238): Change the ListView to a Custom scrollable list.
-                    // TODO (fxb/45239): Implement tab rearrangement for the scrollable tab list.
-                    : ListView(
-                        controller: _scrollController,
-                        scrollDirection: Axis.horizontal,
-                        children: _buildPageTabs(width: _kMinTabWidth),
-                      ),
+                    : _buildScrollableTabListWithButtons(),
               ),
             );
           }
@@ -168,6 +167,50 @@
 
   // 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(
@@ -261,6 +304,7 @@
     renderingIndex ??= index;
 
     return Container(
+      key: Key('tab'),
       width: _tabWidth,
       height: _kTabBarHeight,
       decoration: BoxDecoration(
@@ -289,30 +333,10 @@
   Border _buildBorder(bool hasBorder) => Border(
         left: BorderSide(
           color: hasBorder ? _browserTheme.accentColor : Colors.transparent,
-          width: 1.0,
+          width: _kBorderWidth,
         ),
       );
 
-  // TODO(fxb/45239): This is an old code and will be removed.
-  List<Widget> _buildPageTabs({@required double width}) => widget.bloc.tabs
-      .map(_buildTab)
-      // add a 1pip separator before every tab,
-      // divide the rest of the space between tabs
-      .expand((item) => [
-            SizedBox(
-              width: _kSeparatorWidth,
-              child: Container(
-                color: _browserTheme.accentColor,
-              ),
-            ),
-            width == null
-                ? Expanded(child: item, flex: 1)
-                : SizedBox(child: item, width: width),
-          ])
-      // skip the first separator
-      .skip(1)
-      .toList();
-
   Widget _buildTab(WebPageBloc tab) => _TabWidget(
         bloc: tab,
         selected: tab == widget.bloc.currentTab,
@@ -332,8 +356,7 @@
     if (_scrollController.hasClients) {
       final viewportWidth = _scrollController.position.viewportDimension;
       final currentTabIndex = widget.bloc.currentTabIdx;
-      final currentTabPosition =
-          currentTabIndex * (_kMinTabWidth + _kSeparatorWidth);
+      final currentTabPosition = currentTabIndex * _kMinTabWidth;
 
       final offsetForLeftEdge = currentTabPosition - _kScrollToMargin;
       final offsetForRightEdge =
@@ -350,7 +373,7 @@
       if (newOffset != null) {
         _scrollController.animateTo(
           newOffset,
-          duration: Duration(milliseconds: 300),
+          duration: Duration(milliseconds: _kScrollAnimationDuration),
           curve: Curves.ease,
         );
       }
@@ -374,8 +397,11 @@
 
   void _onDragUpdate(DragUpdateDetails details) {
     double dragOffsetX = details.globalPosition.dx - _dragStartX;
+    double dragXMax = (_scrollController.hasClients)
+        ? (_tabWidth * widget.bloc.tabs.length) - _tabWidth
+        : (_tabListWidth - _tabWidth);
     _currentTabX.value = ((_tabWidth * widget.bloc.currentTabIdx) + dragOffsetX)
-        .clamp(0.0, _tabListWidth - _tabWidth);
+        .clamp(0.0, dragXMax);
 
     if (!_isAnimating) {
       if (_isOverlappingLeftTabHalf()) {
@@ -437,6 +463,35 @@
     _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(_ScrollButtonType.left)) {
+      _leftScrollButton.enable();
+    } else {
+      _leftScrollButton.disable();
+    }
+
+    if (_canScrollTo(_ScrollButtonType.right)) {
+      _rightScrollButton.enable();
+    } else {
+      _rightScrollButton.disable();
+    }
+  }
+
   // CHECKERS
 
   bool _isOverlappingLeftTabHalf() {
@@ -462,6 +517,25 @@
     return false;
   }
 
+  bool _canScrollTo(_ScrollButtonType direction) {
+    switch (direction) {
+      case _ScrollButtonType.left:
+        if (_scrollController.offset <= 0.0) {
+          return false;
+        }
+        return true;
+      case _ScrollButtonType.right:
+        if (_scrollController.offset >=
+            (_kMinTabWidth * widget.bloc.tabs.length -
+                _scrollController.position.viewportDimension)) {
+          return false;
+        }
+        return true;
+      default:
+        return true;
+    }
+  }
+
   // ANIMATORS
 
   void _shiftLeftToRight() {
@@ -535,23 +609,27 @@
                   ),
                 ),
                 Positioned(
-                  top: 0.0,
                   right: 0.0,
-                  bottom: 0.0,
                   child: AnimatedBuilder(
                     animation: _hovering,
                     builder: (_, child) => Offstage(
                       offstage: !(widget.selected || _hovering.value),
                       child: child,
                     ),
-                    child: AspectRatio(
-                      aspectRatio: 1.0,
+                    child: Padding(
+                      padding: EdgeInsets.all(4.0),
                       child: GestureDetector(
                         onTap: widget.onClose,
                         child: Container(
                           color: Colors.transparent,
                           alignment: Alignment.center,
-                          child: Text(_kCloseMark),
+                          child: Icon(
+                            Icons.clear,
+                            color: widget.selected
+                                ? baseTheme.primaryColor
+                                : baseTheme.accentColor,
+                            size: _kIconSize,
+                          ),
                         ),
                       ),
                     ),
@@ -565,3 +643,28 @@
     );
   }
 }
+
+class _ScrollButton {
+  final _ScrollButtonType type;
+  IconData icon;
+  final ValueNotifier<bool> isEnabled = ValueNotifier<bool>(true);
+  double directionFactor = 0.0;
+
+  _ScrollButton(this.type)
+      : assert(
+            type == _ScrollButtonType.left || type == _ScrollButtonType.right) {
+    switch (type) {
+      case _ScrollButtonType.left:
+        icon = Icons.keyboard_arrow_left;
+        directionFactor = -1.0;
+        break;
+      case _ScrollButtonType.right:
+        icon = Icons.keyboard_arrow_right;
+        directionFactor = 1.0;
+        break;
+    }
+  }
+
+  void disable() => isEnabled.value = false;
+  void enable() => isEnabled.value = true;
+}
diff --git a/bin/simple_browser/test/widgets/tabs_widget_test.dart b/bin/simple_browser/test/widgets/tabs_widget_test.dart
index c41d849..21981d6 100644
--- a/bin/simple_browser/test/widgets/tabs_widget_test.dart
+++ b/bin/simple_browser/test/widgets/tabs_widget_test.dart
@@ -18,6 +18,7 @@
 import 'package:simple_browser/src/widgets/tabs_widget.dart';
 
 const _emptyTitle = 'NEW TAB';
+const screenWidth = 800.0;
 
 void main() {
   setupLogger(name: 'tabs_widget_test');
@@ -106,14 +107,13 @@
 
     await _addNTabsToTabsBloc(tester, tabsBloc, 3);
 
-    expect(tabsBloc.currentTabIdx, 2,
-        reason: 'Expected the 3rd tab widget was focused by default.');
+    _verifyFocusedTabIndex(tabsBloc, 2);
 
     final tabs = _findNewTabWidgets();
     await tester.tap(tabs.at(0));
     await tester.pumpAndSettle();
-    expect(tabsBloc.currentTabIdx, 0,
-        reason: 'Expected the 1st tab widget is focused when tapped on it.');
+
+    _verifyFocusedTabIndex(tabsBloc, 0);
   });
 
   testWidgets(
@@ -161,8 +161,7 @@
     await _addNTabsToTabsBloc(tester, tabsBloc, 4);
     expect(_findNewTabWidgets(), findsNWidgets(4),
         reason: 'Expected to find 4 tab widgets when 4 tabs added.');
-    expect(tabsBloc.currentTabIdx, 3,
-        reason: 'Expected the 4th tab widget was focused by default.');
+    _verifyFocusedTabIndex(tabsBloc, 3);
 
     Finder closeButtons = _findClose();
     expect(closeButtons, findsOneWidget,
@@ -174,9 +173,7 @@
         reason: 'Expected 3 tabs in tabsBloc after tapped the close.');
     expect(_findNewTabWidgets(), findsNWidgets(3),
         reason: 'Expected to find 3 tab widgets after tapped the close.');
-    expect(tabsBloc.currentTabIdx, 2,
-        reason:
-            'Expected the 3rd tab widget was focused after tapped the close.');
+    _verifyFocusedTabIndex(tabsBloc, 2);
   });
 
   testWidgets(
@@ -187,8 +184,7 @@
     final tabs = _findNewTabWidgets();
     expect(tabs, findsNWidgets(5),
         reason: 'Expected to find 5 tab widgets when 5 tabs added.');
-    expect(tabsBloc.currentTabIdx, 4,
-        reason: 'Expected the 5th tab widget was focused by default.');
+    _verifyFocusedTabIndex(tabsBloc, 4);
 
     WebPageBloc originalTab0 = tabsBloc.tabs[0];
     WebPageBloc originalTab1 = tabsBloc.tabs[1];
@@ -223,9 +219,7 @@
     await tester.pumpAndSettle();
 
     // Sees if the tab just moved is focused.
-    expect(tabsBloc.currentTabIdx, 2,
-        reason: '''Expected the index of the focused tab to be rearranged to 2,
-            but actually has been rearranged to ${tabsBloc.currentTabIdx}.''');
+    _verifyFocusedTabIndex(tabsBloc, 2);
 
     // Sees if all tabs have been rarranged correctly.
     expect(tabsBloc.tabs[0], originalTab0,
@@ -240,122 +234,179 @@
         reason: 'Expected that the 5th tab used to the 4th.');
   });
 
-  testWidgets(
-      'The tab widget list should scroll if needed depending on the offset of the selected tab.',
-      (WidgetTester tester) async {
-    final rightScrollMargin = 800.0 - kScrollToMargin - kMinTabWidth;
-    final rightMinMargin = 800.0 - kMinTabWidth;
-    final leftScrollMargin = kScrollToMargin;
-    const leftMinMargin = 0.0;
+  group('Scrollable tab list', () {
+    final rightScrollMargin =
+        screenWidth - kTabBarHeight - kScrollToMargin - kMinTabWidth;
+    final rightMinMargin = screenWidth - kTabBarHeight - kMinTabWidth;
+    final leftScrollMargin = kScrollToMargin + kTabBarHeight;
+    final leftMinMargin = kTabBarHeight;
 
-    await _setUpTabsWidget(tester, tabsBloc);
+    testWidgets('The tab widget list should scroll on a scroll button tapped.',
+        (WidgetTester tester) async {
+      await _setUpTabsWidget(tester, tabsBloc);
 
-    // Creates 8 tabs.
-    await _addNTabsToTabsBloc(tester, tabsBloc, 8);
+      // Creates 8 tabs.
+      await _addNTabsToTabsBloc(tester, tabsBloc, 10);
 
-    // The screen(container) width: 800.0
-    // The total width of the tab widgets: 120.0 * 8 = 960.
-    // The expected display of the tab widgets (* is the currently focused tab):
-    // |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7* -|
-    //          |-                     SCREEN                        -|
+      // The expected display of the initial tab list (*currently focused tab):
+      // |-0-||-1-||-2-||-3-||-4-||-5-||-6-||-7-||-8-||-9*-|
+      //                   |-            SCREEN           -|
 
-    // See if the last tab is currenly focused.
-    expect(tabsBloc.currentTabIdx, 7,
-        reason: 'Expected the 8th tab widget is focused by default.');
+      final tabs = _findMinTabWidgets();
 
-    final tabsOnScreen = _findMinTabWidgets();
+      // Sees if there are 8 tab widgets created.
+      expect(tabs, findsNWidgets(10),
+          reason: 'Expected to find 10 tabs, but actually $tabs were found.');
 
-    // Sees if there are 7 tab widgets on the screen.
-    expect(tabsOnScreen, findsNWidgets(7),
-        reason: 'Expected 7 out of 8 tab widgets on the 800-wide viewport.');
+      // Sees if the viewport of the list is on the right.
+      _verifyFocusedTabIndex(tabsBloc, 9);
+      _verifyFocusedTabMargin(tester, tabs, rightMinMargin);
+      _verifyPartlyOffscreenFromLeft(tester, tabs.at(3));
 
-    final lastTab = tabsOnScreen.last;
-    final fixedY = tester.getTopLeft(lastTab).dy;
-    final expectedLastPosition = Offset(rightMinMargin, fixedY);
+      final leftScrollButton = find.byIcon(Icons.keyboard_arrow_left);
+      expect(leftScrollButton, findsOneWidget);
 
-    expect(tester.getTopLeft(lastTab), expectedLastPosition,
-        reason: '''Expected the initial X position of the last tab widget
-        to be $expectedLastPosition.''');
+      final rightScrollButton = find.byIcon(Icons.keyboard_arrow_right);
+      expect(rightScrollButton, findsOneWidget);
 
-    // The fifth tab in the tabsBloc is the forth tab widget on the current screen.
-    final fifthTab = tabsOnScreen.at(3);
-    final expectedFifthPosition = tester.getTopLeft(fifthTab);
+      // Taps on the left scroll button.
+      await tester.tap(leftScrollButton);
+      await tester.pumpAndSettle();
 
-    // Taps on the fifth tab to change the focus.
-    await tester.tap(fifthTab);
-    await tester.pumpAndSettle();
+      // The expected display of the tab list (*currently focused tab):
+      // |-0-||-1-||-2-||-3-||-4-||-5-||-6-||-7-||-8-||-9*-|
+      //  |-            SCREEN           -|
 
-    // The expected display of the tab widgets (* is the currently focused tab):
-    // |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4* -| |- 5 -| |- 6 -| |- 7 -|
-    //          |-                     SCREEN                        -|
+      // Sees if the list has been scrolled to the left.
+      _verifyPartlyOffscreenFromLeft(tester, tabs.first);
+      _verifyEntirelyOffscreenFromRight(tester, tabs.last);
+      _verifyPartlyOffscreenFromRight(tester, tabs.at(6));
 
-    expect(tabsBloc.currentTabIdx, 4,
-        reason: 'Expected the 5th tab to be focused when tapped on it.');
-    expect(tester.getTopLeft(fifthTab), expectedFifthPosition,
-        reason: '''Expected the tab widget list stay still when tapped on
-        the 5th tab, which is the 4th tab widget on the screen.''');
+      await tester.tap(leftScrollButton);
+      await tester.pumpAndSettle();
 
-    // The second tab in the tabsBloc is the first tab widget on the current screen.
-    final secondTab = tabsOnScreen.at(0);
+      // The expected display of the tab list (*currently focused tab):
+      // |-0-||-1-||-2-||-3-||-4-||-5-||-6-||-7-||-8-||-9*-|
+      // |-            SCREEN           -|
 
-    final expectedSecondPosition = Offset(leftScrollMargin, fixedY);
-    await tester.tap(secondTab);
-    await tester.pumpAndSettle();
+      // Sees if the list has been scrolled to the left end.
+      final firstTabLeftX = tester.getTopLeft(tabs.first).dx;
+      expect(firstTabLeftX, leftMinMargin,
+          reason: '''Expected the scroll list have been scroll to its far left,
+          but actually it has not.''');
 
-    // The expected display of the tab widgets (* is the currently focused tab):
-    //      |- 0 -| |- 1* -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7 -|
-    //          |-                     SCREEN                        -|
+      // Taps on the right scroll button.
+      await tester.tap(rightScrollButton);
+      await tester.pumpAndSettle();
 
-    expect(tabsOnScreen, findsNWidgets(8),
-        reason: '''Expected 8 tab widgets on the screen when tapped on the
-        2nd tab, which was the 1st tab widget on the screen.''');
-    expect(tabsBloc.currentTabIdx, 1,
-        reason: '''Expected the 2nd tab to be focused when tapped on it.''');
-    expect(tester.getTopLeft(tabsOnScreen.at(1)), expectedSecondPosition,
-        reason: '''Expected the tab widget list to shift from left to right
-        and the 2nd tab widget to be fully revealed on the screen when
-        tapped on it.''');
+      // The expected display of the tab list (*currently focused tab):
+      // |-0-||-1-||-2-||-3-||-4-||-5-||-6-||-7-||-8-||-9*-|
+      //                  |-            SCREEN           -|
 
-    final expectedFirstPosition = Offset(leftMinMargin, fixedY);
-    // Directly adds the FocusTabAction to the tabsBloc since tester.tap() does not
-    // work on the widget whose center is offscreen.
-    tabsBloc.request.add(FocusTabAction(tab: tabsBloc.tabs[0]));
-    await tester.pumpAndSettle();
+      // Sees if the list has been scrolled to the right.
+      _verifyPartlyOffscreenFromLeft(tester, tabs.at(3));
+      _verifyPartlyOffscreenFromRight(tester, tabs.last);
 
-    // The expected display of the tab widgets (* is the currently focused tab):
-    //          |- 0* -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7 -|
-    //          |-                     SCREEN                        -|
+      await tester.tap(rightScrollButton);
+      await tester.pumpAndSettle();
 
-    expect(tabsOnScreen, findsNWidgets(7),
-        reason:
-            'Expected 7 tab widgets on the screen when 1st tab is focused.');
+      // The expected display of the tab list (*currently focused tab):
+      // |-0-||-1-||-2-||-3-||-4-||-5-||-6-||-7-||-8-||-9*-|
+      //                   |-            SCREEN           -|
 
-    expect(tabsBloc.currentTabIdx, 0,
-        reason: 'Expected the 1st tab to be focused when moved focus on it.');
-    expect(tester.getTopLeft(tabsOnScreen.at(0)), expectedFirstPosition,
-        reason: '''Expected the tab widget list to shift from left to right
-            and the 1st tab widget to be fully revealed on the screen when
-            tapped on it.''');
+      // Sees if the list has been scrolled to the right end.
+      _verifyFocusedTabMargin(tester, tabs, rightMinMargin);
+      _verifyPartlyOffscreenFromLeft(tester, tabs.at(3));
+    });
 
-    final seventhTab = tabsOnScreen.at(6);
-    final expectedSeventhPosition = Offset(rightScrollMargin, fixedY);
-    await tester.tap(seventhTab);
-    await tester.pumpAndSettle();
+    testWidgets(
+        'The tab widget list should scroll if needed depending on the offset of the focused tab.',
+        (WidgetTester tester) async {
+      await _setUpTabsWidget(tester, tabsBloc);
 
-    // The expected display of the tab widgets (* is the currently focused tab):
-    //     |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6* -| |- 7 -|
-    //          |-                     SCREEN                        -|
+      // Creates 8 tabs.
+      await _addNTabsToTabsBloc(tester, tabsBloc, 8);
 
-    expect(tabsOnScreen, findsNWidgets(8),
-        reason: '''Expected 8 tab widgets on the screen when tapped on the
-        7th tab widget.''');
+      // The expected display of the initial tab list (*currently focused tab):
+      // |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7* -|
+      //              |-                     SCREEN                    -|
 
-    expect(tabsBloc.currentTabIdx, 6,
-        reason: '''Expected the 7th tab to be focused when tapped on it.''');
-    expect(tester.getTopLeft(seventhTab), expectedSeventhPosition,
-        reason: '''Expected the tab widget list to shift from right to left
-            and the 7th tab widget to be fully revealed on the screen when
-            tapped on it.''');
+      final tabs = _findMinTabWidgets();
+
+      // Sees if there are 8 tab widgets created.
+      expect(tabs, findsNWidgets(8),
+          reason: 'Expected to find 8 tabs, but actually $tabs were found.');
+
+      // Sees if the expected tab is currently focused and its at the desired
+      // position.
+      _verifyFocusedTabIndex(tabsBloc, 7);
+      _verifyFocusedTabMargin(tester, tabs, rightMinMargin);
+
+      // Sees if certain tabs are partly/entire offscreen.
+      _verifyEntirelyOffscreenFromLeft(tester, tabs.first);
+      _verifyPartlyOffscreenFromLeft(tester, tabs.at(1));
+
+      // Finds the fifth tab and checks its current position.
+      final fifthTab = tabs.at(4);
+      final expectedFifthTabLeftX = tester.getTopLeft(fifthTab).dx;
+
+      // Taps on the fifth tab to change the focus.
+      await tester.tap(fifthTab);
+      await tester.pumpAndSettle();
+
+      // The expected display of the tab list (*currently focused tab):
+      // |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4* -| |- 5 -| |- 6 -| |- 7 -|
+      //              |-                     SCREEN                    -|
+
+      _verifyFocusedTabIndex(tabsBloc, 4);
+      _verifyFocusedTabMargin(tester, tabs, expectedFifthTabLeftX);
+
+      // Focuses on the second tab by directly adding the FocusTabAction to the
+      // tabsBloc since tester.tap() does not work on the widget whose center
+      // is offscreen.
+      tabsBloc.request.add(FocusTabAction(tab: tabsBloc.tabs[1]));
+      await tester.pumpAndSettle();
+
+      // The expected display of the tab list (*currently focused tab):
+      // |- 0 -| |- 1* -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7 -|
+      //     |-                     SCREEN                    -|
+
+      _verifyFocusedTabIndex(tabsBloc, 1);
+      _verifyFocusedTabMargin(tester, tabs, leftScrollMargin);
+
+      _verifyPartlyOffscreenFromLeft(tester, tabs.first);
+      _verifyPartlyOffscreenFromRight(tester, tabs.at(5));
+      _verifyEntirelyOffscreenFromRight(tester, tabs.at(6));
+
+      // Focuses on the first tab.
+      tabsBloc.request.add(FocusTabAction(tab: tabsBloc.tabs[0]));
+      await tester.pumpAndSettle();
+
+      // The expected display of the tab list (*currently focused tab):
+      // |- 0* -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6 -| |- 7 -|
+      // |-                     SCREEN                    -|
+
+      _verifyFocusedTabIndex(tabsBloc, 0);
+      _verifyFocusedTabMargin(tester, tabs, leftMinMargin);
+
+      _verifyPartlyOffscreenFromRight(tester, tabs.at(5));
+      _verifyEntirelyOffscreenFromRight(tester, tabs.at(6));
+
+      // Focuses on the seventh tab.
+      tabsBloc.request.add(FocusTabAction(tab: tabsBloc.tabs[6]));
+      await tester.pumpAndSettle();
+
+      // The expected display of the tab list (*currently focused tab):
+      // |- 0 -| |- 1 -| |- 2 -| |- 3 -| |- 4 -| |- 5 -| |- 6* -| |- 7 -|
+      //          |-                     SCREEN                    -|
+
+      _verifyFocusedTabIndex(tabsBloc, 6);
+      _verifyFocusedTabMargin(tester, tabs, rightScrollMargin);
+
+      _verifyEntirelyOffscreenFromLeft(tester, tabs.first);
+      _verifyPartlyOffscreenFromLeft(tester, tabs.at(1));
+      _verifyPartlyOffscreenFromRight(tester, tabs.at(6));
+    });
   });
 }
 
@@ -363,7 +414,7 @@
   await tester.pumpWidget(MaterialApp(
     home: Scaffold(
       body: Container(
-        width: 800,
+        width: screenWidth,
         child: TabsWidget(
           bloc: tabsBloc,
         ),
@@ -384,12 +435,76 @@
   expect(tabsBloc.tabs.length, currentNumTabs + n);
 }
 
-Finder _findMinTabWidgets() => find.byWidgetPredicate(
-    (Widget widget) => widget is SizedBox && (widget.width) == kMinTabWidth);
+Finder _findMinTabWidgets() => find.byWidgetPredicate((Widget widget) {
+      if (widget is Container && widget.key == Key('tab')) {
+        BoxConstraints width = widget.constraints.widthConstraints();
+        return (width.minWidth == width.maxWidth) &&
+            (width.minWidth == kMinTabWidth);
+      }
+      return false;
+    });
 
 Finder _findNewTabWidgets() => find.text(_emptyTitle);
 
-Finder _findClose() => find.text(kCloseMark);
+Finder _findClose() => find.byIcon(Icons.clear);
+
+// Verifies if the currently focused tab is the expected tab.
+void _verifyFocusedTabIndex(TabsBloc tb, int expectedIndex) {
+  expect(tb.currentTabIdx, expectedIndex,
+      reason: '''Expected the index of the currently focused tab to be
+      $expectedIndex, but actually is ${tb.currentTabIdx}''');
+}
+
+// Verifies if the currently focused tab has the expected left margin.
+void _verifyFocusedTabMargin(
+    WidgetTester tester, Finder tabs, double expectedMargin) {
+  // The currently focused tab always become the last member of the finder
+  // since it is rendered on the top of the other tabs.
+  final actualMargin = tester.getTopLeft(tabs.last).dx;
+  expect(actualMargin, expectedMargin,
+      reason: '''Expected the left margin to the currently focused tab to be
+        $expectedMargin, but is actually $actualMargin.''');
+}
+
+void _verifyPartlyOffscreenFromLeft(WidgetTester tester, Finder tab) {
+  final leftBorderX = kTabBarHeight;
+  final tabLeftX = tester.getTopLeft(tab).dx;
+  final tabRightX = tester.getTopRight(tab).dx;
+  expect(tabLeftX < leftBorderX, true,
+      reason: '''Expected this tab's left edge to be offscreen,
+          but actually is onscreen.''');
+  expect(tabRightX >= leftBorderX, true,
+      reason: '''Expected this tab's right edge to be onscreen,
+      but actually is offscreen.''');
+}
+
+void _verifyEntirelyOffscreenFromLeft(WidgetTester tester, Finder tab) {
+  final leftBorderX = kTabBarHeight;
+  final tabRightX = tester.getTopRight(tab).dx;
+  expect(tabRightX < leftBorderX, true,
+      reason: '''Expected this tab's right edge to be offscreen,
+      but actually is onscreen.''');
+}
+
+void _verifyPartlyOffscreenFromRight(WidgetTester tester, Finder tab) {
+  final rightBorderX = screenWidth - kTabBarHeight;
+  final tabLeftX = tester.getTopLeft(tab).dx;
+  final tabRightX = tester.getTopRight(tab).dx;
+  expect(tabLeftX < rightBorderX, true,
+      reason: '''Expected this tab's left edge to be onscreen,
+          but actually is offscreen.''');
+  expect(tabRightX >= rightBorderX, true,
+      reason: '''Expected this tab's right edge to be offscreen,
+      but actually is onscreen.''');
+}
+
+void _verifyEntirelyOffscreenFromRight(WidgetTester tester, Finder tab) {
+  final rightBorderX = screenWidth - kTabBarHeight;
+  final tabLeftX = tester.getTopLeft(tab).dx;
+  expect(tabLeftX > rightBorderX, true,
+      reason: '''Expected this tab's left edge to be offscreen,
+      but actually is onscreen.''');
+}
 
 class MockSimpleBrowserNavigationEventListener extends Mock
     implements SimpleBrowserNavigationEventListener {}