tab_controller.dart 16.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hans Muller's avatar
Hans Muller committed
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:math' as math;

Hans Muller's avatar
Hans Muller committed
7 8 9 10
import 'package:flutter/widgets.dart';

import 'constants.dart';

11
// Examples can assume:
12
// late BuildContext context;
13

Hans Muller's avatar
Hans Muller committed
14 15 16
/// Coordinates tab selection between a [TabBar] and a [TabBarView].
///
/// The [index] property is the index of the selected tab and the [animation]
Aditya Sharma's avatar
Aditya Sharma committed
17
/// represents the current scroll positions of the tab bar and the tab bar view.
Hans Muller's avatar
Hans Muller committed
18 19
/// The selected tab's index can be changed with [animateTo].
///
20
/// A stateful widget that builds a [TabBar] or a [TabBarView] can create
21 22 23
/// a [TabController] and share it directly.
///
/// When the [TabBar] and [TabBarView] don't have a convenient stateful
24 25
/// ancestor, a [TabController] can be shared by providing a
/// [DefaultTabController] inherited widget.
Hans Muller's avatar
Hans Muller committed
26
///
27 28
/// {@animation 700 540 https://flutter.github.io/assets-for-api-docs/assets/material/tabs.mp4}
///
29
/// {@tool snippet}
30
///
31 32
/// This widget introduces a [Scaffold] with an [AppBar] and a [TabBar].
///
33
/// ```dart
34
/// class MyTabbedPage extends StatefulWidget {
35
///   const MyTabbedPage({ super.key });
36
///   @override
37
///   State<MyTabbedPage> createState() => _MyTabbedPageState();
38 39 40
/// }
///
/// class _MyTabbedPageState extends State<MyTabbedPage> with SingleTickerProviderStateMixin {
41
///   static const List<Tab> myTabs = <Tab>[
42 43
///     Tab(text: 'LEFT'),
///     Tab(text: 'RIGHT'),
44 45
///   ];
///
46
///   late TabController _tabController;
47 48 49 50
///
///   @override
///   void initState() {
///     super.initState();
51
///     _tabController = TabController(vsync: this, length: myTabs.length);
52 53 54 55 56 57 58 59 60 61
///   }
///
///  @override
///  void dispose() {
///    _tabController.dispose();
///    super.dispose();
///  }
///
///   @override
///   Widget build(BuildContext context) {
62 63 64
///     return Scaffold(
///       appBar: AppBar(
///         bottom: TabBar(
65 66 67 68
///           controller: _tabController,
///           tabs: myTabs,
///         ),
///       ),
69
///       body: TabBarView(
70 71
///         controller: _tabController,
///         children: myTabs.map((Tab tab) {
72
///           final String label = tab.text!.toLowerCase();
73 74 75 76 77 78
///           return Center(
///             child: Text(
///               'This is the $label tab',
///               style: const TextStyle(fontSize: 36),
///             ),
///           );
79 80 81 82 83 84
///         }).toList(),
///       ),
///     );
///   }
/// }
/// ```
85
/// {@end-tool}
86
///
87
/// {@tool dartpad}
88 89 90
/// This example shows how to listen to page updates in [TabBar] and [TabBarView]
/// when using [DefaultTabController].
///
91
/// ** See code in examples/api/lib/material/tab_controller/tab_controller.1.dart **
92 93
/// {@end-tool}
///
Hans Muller's avatar
Hans Muller committed
94
class TabController extends ChangeNotifier {
95 96
  /// Creates an object that manages the state required by [TabBar] and a
  /// [TabBarView].
97
  ///
98
  /// The [length] must not be null or negative. Typically it's a value greater
99 100
  /// than one, i.e. typically there are two or more tabs. The [length] must
  /// match [TabBar.tabs]'s and [TabBarView.children]'s length.
101
  ///
102 103
  /// The `initialIndex` must be valid given [length] and must not be null. If
  /// [length] is zero, then `initialIndex` must be 0 (the default).
104
  TabController({ int initialIndex = 0, Duration? animationDuration, required this.length, required TickerProvider vsync})
105 106
    : assert(length != null && length >= 0),
      assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)),
107
      _index = initialIndex,
Hans Muller's avatar
Hans Muller committed
108
      _previousIndex = initialIndex,
109
      _animationDuration = animationDuration ?? kTabScrollDuration,
110
      _animationController = AnimationController.unbounded(
Hans Muller's avatar
Hans Muller committed
111
        value: initialIndex.toDouble(),
112
        vsync: vsync,
113
      );
Hans Muller's avatar
Hans Muller committed
114

115 116 117
  // Private constructor used by `_copyWith`. This allows a new TabController to
  // be created without having to create a new animationController.
  TabController._({
118 119 120
    required int index,
    required int previousIndex,
    required AnimationController? animationController,
121
    required Duration animationDuration,
122
    required this.length,
123 124
  }) : _index = index,
       _previousIndex = previousIndex,
125 126
       _animationController = animationController,
       _animationDuration = animationDuration;
127 128


129 130
  /// Creates a new [TabController] with `index`, `previousIndex`, `length`, and
  /// `animationDuration` if they are non-null.
131
  ///
132
  /// This method is used by [DefaultTabController].
133
  ///
134 135
  /// When [DefaultTabController.length] is updated, this method is called to
  /// create a new [TabController] without creating a new [AnimationController].
136 137 138 139
  TabController _copyWith({
    required int? index,
    required int? length,
    required int? previousIndex,
140
    required Duration? animationDuration,
141
  }) {
142 143 144
    if (index != null) {
      _animationController!.value = index.toDouble();
    }
145 146 147 148 149
    return TabController._(
      index: index ?? _index,
      length: length ?? this.length,
      animationController: _animationController,
      previousIndex: previousIndex ?? _previousIndex,
150
      animationDuration: animationDuration ?? _animationDuration,
151 152 153
    );
  }

Hans Muller's avatar
Hans Muller committed
154 155 156 157 158 159 160 161
  /// An animation whose value represents the current position of the [TabBar]'s
  /// selected tab indicator as well as the scrollOffsets of the [TabBar]
  /// and [TabBarView].
  ///
  /// The animation's value ranges from 0.0 to [length] - 1.0. After the
  /// selected tab is changed, the animation's value equals [index]. The
  /// animation's value can be [offset] by +/- 1.0 to reflect [TabBarView]
  /// drag scrolling.
162
  ///
163
  /// If this [TabController] was disposed, then return null.
164 165
  Animation<double>? get animation => _animationController?.view;
  AnimationController? _animationController;
Hans Muller's avatar
Hans Muller committed
166

167 168 169 170 171 172
  /// Controls the duration of TabController and TabBarView animations.
  ///
  /// Defaults to kTabScrollDuration.
  Duration get animationDuration => _animationDuration;
  final Duration _animationDuration;

173 174 175 176
  /// The total number of tabs.
  ///
  /// Typically greater than one. Must match [TabBar.tabs]'s and
  /// [TabBarView.children]'s length.
Hans Muller's avatar
Hans Muller committed
177 178
  final int length;

179
  void _changeIndex(int value, { Duration? duration, Curve? curve }) {
Hans Muller's avatar
Hans Muller committed
180
    assert(value != null);
181
    assert(value >= 0 && (value < length || length == 0));
182
    assert(duration != null || curve == null);
Hans Muller's avatar
Hans Muller committed
183
    assert(_indexIsChangingCount >= 0);
184
    if (value == _index || length < 2) {
Hans Muller's avatar
Hans Muller committed
185
      return;
186
    }
Hans Muller's avatar
Hans Muller committed
187 188
    _previousIndex = index;
    _index = value;
189
    if (duration != null && duration > Duration.zero) {
Hans Muller's avatar
Hans Muller committed
190
      _indexIsChangingCount += 1;
191
      notifyListeners(); // Because the value of indexIsChanging may have changed.
192 193
      _animationController!
        .animateTo(_index.toDouble(), duration: duration, curve: curve!)
194
        .whenCompleteOrCancel(() {
195 196 197 198
          if (_animationController != null) { // don't notify if we've been disposed
            _indexIsChangingCount -= 1;
            notifyListeners();
          }
199
        });
Hans Muller's avatar
Hans Muller committed
200 201
    } else {
      _indexIsChangingCount += 1;
202
      _animationController!.value = _index.toDouble();
Hans Muller's avatar
Hans Muller committed
203 204 205 206 207
      _indexIsChangingCount -= 1;
      notifyListeners();
    }
  }

208 209 210 211
  /// The index of the currently selected tab.
  ///
  /// Changing the index also updates [previousIndex], sets the [animation]'s
  /// value to index, resets [indexIsChanging] to false, and notifies listeners.
Hans Muller's avatar
Hans Muller committed
212 213
  ///
  /// To change the currently selected tab and play the [animation] use [animateTo].
214 215 216
  ///
  /// The value of [index] must be valid given [length]. If [length] is zero,
  /// then [index] will also be zero.
Hans Muller's avatar
Hans Muller committed
217 218 219 220 221 222
  int get index => _index;
  int _index;
  set index(int value) {
    _changeIndex(value);
  }

223 224 225
  /// The index of the previously selected tab.
  ///
  /// Initially the same as [index].
Hans Muller's avatar
Hans Muller committed
226 227 228
  int get previousIndex => _previousIndex;
  int _previousIndex;

229 230 231 232 233 234
  /// True while we're animating from [previousIndex] to [index] as a
  /// consequence of calling [animateTo].
  ///
  /// This value is true during the [animateTo] animation that's triggered when
  /// the user taps a [TabBar] tab. It is false when [offset] is changing as a
  /// consequence of the user dragging (and "flinging") the [TabBarView].
Hans Muller's avatar
Hans Muller committed
235 236 237 238 239 240 241 242
  bool get indexIsChanging => _indexIsChangingCount != 0;
  int _indexIsChangingCount = 0;

  /// Immediately sets [index] and [previousIndex] and then plays the
  /// [animation] from its current value to [index].
  ///
  /// While the animation is running [indexIsChanging] is true. When the
  /// animation completes [offset] will be 0.0.
243 244
  void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) {
    _changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
Hans Muller's avatar
Hans Muller committed
245 246
  }

247 248 249
  /// The difference between the [animation]'s value and [index].
  ///
  /// The offset value must be between -1.0 and 1.0.
Hans Muller's avatar
Hans Muller committed
250 251 252 253 254
  ///
  /// This property is typically set by the [TabBarView] when the user
  /// drags left or right. A value between -1.0 and 0.0 implies that the
  /// TabBarView has been dragged to the left. Similarly a value between
  /// 0.0 and 1.0 implies that the TabBarView has been dragged to the right.
255
  double get offset => _animationController!.value - _index.toDouble();
256 257 258
  set offset(double value) {
    assert(value != null);
    assert(value >= -1.0 && value <= 1.0);
Hans Muller's avatar
Hans Muller committed
259
    assert(!indexIsChanging);
260
    if (value == offset) {
Hans Muller's avatar
Hans Muller committed
261
      return;
262
    }
263
    _animationController!.value = value + _index.toDouble();
Hans Muller's avatar
Hans Muller committed
264 265 266 267
  }

  @override
  void dispose() {
268
    _animationController?.dispose();
269
    _animationController = null;
Hans Muller's avatar
Hans Muller committed
270 271 272 273 274
    super.dispose();
  }
}

class _TabControllerScope extends InheritedWidget {
275
  const _TabControllerScope({
276 277
    required this.controller,
    required this.enabled,
278 279
    required super.child,
  });
Hans Muller's avatar
Hans Muller committed
280 281 282 283 284 285 286 287 288 289

  final TabController controller;
  final bool enabled;

  @override
  bool updateShouldNotify(_TabControllerScope old) {
    return enabled != old.enabled || controller != old.controller;
  }
}

290 291
/// The [TabController] for descendant widgets that don't specify one
/// explicitly.
292
///
293 294
/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40}
///
295 296 297 298 299
/// [DefaultTabController] is an inherited widget that is used to share a
/// [TabController] with a [TabBar] or a [TabBarView]. It's used when sharing an
/// explicitly created [TabController] isn't convenient because the tab bar
/// widgets are created by a stateless parent widget or by different parent
/// widgets.
300
///
301 302
/// {@animation 700 540 https://flutter.github.io/assets-for-api-docs/assets/material/tabs.mp4}
///
303 304
/// ```dart
/// class MyDemo extends StatelessWidget {
305 306 307
///   const MyDemo({super.key});
///
///   static const List<Tab> myTabs = <Tab>[
308 309
///     Tab(text: 'LEFT'),
///     Tab(text: 'RIGHT'),
310 311 312 313
///   ];
///
///   @override
///   Widget build(BuildContext context) {
314
///     return DefaultTabController(
315
///       length: myTabs.length,
316 317
///       child: Scaffold(
///         appBar: AppBar(
318
///           bottom: const TabBar(
319 320 321
///             tabs: myTabs,
///           ),
///         ),
322
///         body: TabBarView(
323
///           children: myTabs.map((Tab tab) {
324
///             final String label = tab.text!.toLowerCase();
325 326 327 328 329 330
///             return Center(
///               child: Text(
///                 'This is the $label tab',
///                 style: const TextStyle(fontSize: 36),
///               ),
///             );
331 332 333 334 335 336 337
///           }).toList(),
///         ),
///       ),
///     );
///   }
/// }
/// ```
Hans Muller's avatar
Hans Muller committed
338
class DefaultTabController extends StatefulWidget {
339 340
  /// Creates a default tab controller for the given [child] widget.
  ///
341 342
  /// The [length] argument is typically greater than one. The [length] must
  /// match [TabBar.tabs]'s and [TabBarView.children]'s length.
343 344
  ///
  /// The [initialIndex] argument must not be null.
345
  const DefaultTabController({
346
    super.key,
347
    required this.length,
348
    this.initialIndex = 0,
349
    required this.child,
350
    this.animationDuration,
351
  }) : assert(initialIndex != null),
352
       assert(length >= 0),
353
       assert(length == 0 || (initialIndex >= 0 && initialIndex < length));
Hans Muller's avatar
Hans Muller committed
354

355 356 357 358
  /// The total number of tabs.
  ///
  /// Typically greater than one. Must match [TabBar.tabs]'s and
  /// [TabBarView.children]'s length.
Hans Muller's avatar
Hans Muller committed
359 360 361
  final int length;

  /// The initial index of the selected tab.
362 363
  ///
  /// Defaults to zero.
Hans Muller's avatar
Hans Muller committed
364 365
  final int initialIndex;

366 367 368 369 370
  /// Controls the duration of DefaultTabController and TabBarView animations.
  ///
  /// Defaults to kTabScrollDuration.
  final Duration? animationDuration;

371 372 373 374
  /// The widget below this widget in the tree.
  ///
  /// Typically a [Scaffold] whose [AppBar] includes a [TabBar].
  ///
375
  /// {@macro flutter.widgets.ProxyWidget.child}
Hans Muller's avatar
Hans Muller committed
376 377
  final Widget child;

378 379
  /// The closest instance of [DefaultTabController] that encloses the given
  /// context, or null if none is found.
Hans Muller's avatar
Hans Muller committed
380
  ///
381
  /// {@tool snippet} Typical usage is as follows:
Hans Muller's avatar
Hans Muller committed
382 383
  ///
  /// ```dart
384
  /// TabController? controller = DefaultTabController.maybeOf(context);
Hans Muller's avatar
Hans Muller committed
385
  /// ```
386
  /// {@end-tool}
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
  ///
  /// Calling this method will create a dependency on the closest
  /// [DefaultTabController] in the [context], if there is one.
  ///
  /// See also:
  ///
  /// * [DefaultTabController.of], which is similar to this method, but asserts
  ///   if no [DefaultTabController] ancestor is found.
  static TabController? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<_TabControllerScope>()?.controller;
  }

  /// The closest instance of [DefaultTabController] that encloses the given
  /// context.
  ///
  /// If no instance is found, this method will assert in debug mode and throw
  /// an exception in release mode.
  ///
  /// Calling this method will create a dependency on the closest
  /// [DefaultTabController] in the [context].
  ///
  /// {@tool snippet} Typical usage is as follows:
  ///
  /// ```dart
  /// TabController controller = DefaultTabController.of(context);
  /// ```
  /// {@end-tool}
  ///
  /// See also:
  ///
  /// * [DefaultTabController.maybeOf], which is similar to this method, but
  ///   returns null if no [DefaultTabController] ancestor is found.
  static TabController of(BuildContext context) {
    final TabController? controller = maybeOf(context);
    assert(() {
      if (controller == null) {
        throw FlutterError(
          'DefaultTabController.of() was called with a context that does not '
          'contain a DefaultTabController widget.\n'
          'No DefaultTabController widget ancestor could be found starting from '
          'the context that was passed to DefaultTabController.of(). This can '
          'happen because you are using a widget that looks for a DefaultTabController '
          'ancestor, but no such ancestor exists.\n'
          'The context used was:\n'
          '  $context',
        );
      }
      return true;
    }());
    return controller!;
Hans Muller's avatar
Hans Muller committed
437 438 439
  }

  @override
440
  State<DefaultTabController> createState() => _DefaultTabControllerState();
Hans Muller's avatar
Hans Muller committed
441 442 443
}

class _DefaultTabControllerState extends State<DefaultTabController> with SingleTickerProviderStateMixin {
444
  late TabController _controller;
Hans Muller's avatar
Hans Muller committed
445 446 447 448

  @override
  void initState() {
    super.initState();
449
    _controller = TabController(
Hans Muller's avatar
Hans Muller committed
450
      vsync: this,
451 452
      length: widget.length,
      initialIndex: widget.initialIndex,
453
      animationDuration: widget.animationDuration,
Hans Muller's avatar
Hans Muller committed
454 455 456 457 458 459 460 461 462 463 464
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
465
    return _TabControllerScope(
Hans Muller's avatar
Hans Muller committed
466 467
      controller: _controller,
      enabled: TickerMode.of(context),
468
      child: widget.child,
Hans Muller's avatar
Hans Muller committed
469 470
    );
  }
471 472 473 474 475 476 477

  @override
  void didUpdateWidget(DefaultTabController oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.length != widget.length) {
      // If the length is shortened while the last tab is selected, we should
      // automatically update the index of the controller to be the new last tab.
478
      int? newIndex;
479 480 481 482 483 484 485
      int previousIndex = _controller.previousIndex;
      if (_controller.index >= widget.length) {
        newIndex = math.max(0, widget.length - 1);
        previousIndex = _controller.index;
      }
      _controller = _controller._copyWith(
        length: widget.length,
486
        animationDuration: widget.animationDuration,
487 488 489 490
        index: newIndex,
        previousIndex: previousIndex,
      );
    }
491 492 493 494 495 496 497 498 499

    if (oldWidget.animationDuration != widget.animationDuration) {
      _controller = _controller._copyWith(
        length: widget.length,
        animationDuration: widget.animationDuration,
        index: _controller.index,
        previousIndex: _controller.previousIndex,
      );
    }
500
  }
Hans Muller's avatar
Hans Muller committed
501
}