tab_controller.dart 16.7 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;

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

import 'constants.dart';

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

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

124 125 126
  // Private constructor used by `_copyWith`. This allows a new TabController to
  // be created without having to create a new animationController.
  TabController._({
127 128 129
    required int index,
    required int previousIndex,
    required AnimationController? animationController,
130
    required Duration animationDuration,
131
    required this.length,
132 133
  }) : _index = index,
       _previousIndex = previousIndex,
134
       _animationController = animationController,
135 136 137 138 139
       _animationDuration = animationDuration {
    if (kFlutterMemoryAllocationsEnabled) {
      ChangeNotifier.maybeDispatchObjectCreation(this);
    }
  }
140

141 142
  /// Creates a new [TabController] with `index`, `previousIndex`, `length`, and
  /// `animationDuration` if they are non-null.
143
  ///
144
  /// This method is used by [DefaultTabController].
145
  ///
146 147
  /// When [DefaultTabController.length] is updated, this method is called to
  /// create a new [TabController] without creating a new [AnimationController].
148 149 150 151
  ///
  /// This instance of [TabController] will be disposed and must not be used
  /// anymore.
  TabController _copyWithAndDispose({
152 153 154
    required int? index,
    required int? length,
    required int? previousIndex,
155
    required Duration? animationDuration,
156
  }) {
157 158 159
    if (index != null) {
      _animationController!.value = index.toDouble();
    }
160
    final TabController newController = TabController._(
161 162 163 164
      index: index ?? _index,
      length: length ?? this.length,
      animationController: _animationController,
      previousIndex: previousIndex ?? _previousIndex,
165
      animationDuration: animationDuration ?? _animationDuration,
166
    );
167 168 169 170 171 172 173

    // Nulling _animationController to not dispose it. It will be disposed by
    // the newly created instance of the TabController.
    _animationController = null;
    dispose();

    return newController;
174 175
  }

Hans Muller's avatar
Hans Muller committed
176 177 178 179 180 181 182 183
  /// 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.
184
  ///
185
  /// If this [TabController] was disposed, then return null.
186 187
  Animation<double>? get animation => _animationController?.view;
  AnimationController? _animationController;
Hans Muller's avatar
Hans Muller committed
188

189 190 191 192 193 194
  /// Controls the duration of TabController and TabBarView animations.
  ///
  /// Defaults to kTabScrollDuration.
  Duration get animationDuration => _animationDuration;
  final Duration _animationDuration;

195 196 197 198
  /// 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
199 200
  final int length;

201
  void _changeIndex(int value, { Duration? duration, Curve? curve }) {
202
    assert(value >= 0 && (value < length || length == 0));
203
    assert(duration != null || curve == null);
Hans Muller's avatar
Hans Muller committed
204
    assert(_indexIsChangingCount >= 0);
205
    if (value == _index || length < 2) {
Hans Muller's avatar
Hans Muller committed
206
      return;
207
    }
Hans Muller's avatar
Hans Muller committed
208 209
    _previousIndex = index;
    _index = value;
210
    if (duration != null && duration > Duration.zero) {
Hans Muller's avatar
Hans Muller committed
211
      _indexIsChangingCount += 1;
212
      notifyListeners(); // Because the value of indexIsChanging may have changed.
213 214
      _animationController!
        .animateTo(_index.toDouble(), duration: duration, curve: curve!)
215
        .whenCompleteOrCancel(() {
216 217 218 219
          if (_animationController != null) { // don't notify if we've been disposed
            _indexIsChangingCount -= 1;
            notifyListeners();
          }
220
        });
Hans Muller's avatar
Hans Muller committed
221 222
    } else {
      _indexIsChangingCount += 1;
223
      _animationController!.value = _index.toDouble();
Hans Muller's avatar
Hans Muller committed
224 225 226 227 228
      _indexIsChangingCount -= 1;
      notifyListeners();
    }
  }

229 230 231 232
  /// 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
233 234
  ///
  /// To change the currently selected tab and play the [animation] use [animateTo].
235 236 237
  ///
  /// 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
238 239 240 241 242 243
  int get index => _index;
  int _index;
  set index(int value) {
    _changeIndex(value);
  }

244 245 246
  /// The index of the previously selected tab.
  ///
  /// Initially the same as [index].
Hans Muller's avatar
Hans Muller committed
247 248 249
  int get previousIndex => _previousIndex;
  int _previousIndex;

250 251 252 253 254 255
  /// 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
256 257 258 259 260 261 262 263
  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.
264 265
  void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) {
    _changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
Hans Muller's avatar
Hans Muller committed
266 267
  }

268 269 270
  /// 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
271 272 273 274 275
  ///
  /// 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.
276
  double get offset => _animationController!.value - _index.toDouble();
277 278
  set offset(double value) {
    assert(value >= -1.0 && value <= 1.0);
Hans Muller's avatar
Hans Muller committed
279
    assert(!indexIsChanging);
280
    if (value == offset) {
Hans Muller's avatar
Hans Muller committed
281
      return;
282
    }
283
    _animationController!.value = value + _index.toDouble();
Hans Muller's avatar
Hans Muller committed
284 285 286 287
  }

  @override
  void dispose() {
288
    _animationController?.dispose();
289
    _animationController = null;
Hans Muller's avatar
Hans Muller committed
290 291 292 293 294
    super.dispose();
  }
}

class _TabControllerScope extends InheritedWidget {
295
  const _TabControllerScope({
296 297
    required this.controller,
    required this.enabled,
298 299
    required super.child,
  });
Hans Muller's avatar
Hans Muller committed
300 301 302 303 304 305 306 307 308 309

  final TabController controller;
  final bool enabled;

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

310 311
/// The [TabController] for descendant widgets that don't specify one
/// explicitly.
312
///
313 314
/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40}
///
315 316 317 318 319
/// [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.
320
///
321 322
/// {@animation 700 540 https://flutter.github.io/assets-for-api-docs/assets/material/tabs.mp4}
///
323 324
/// ```dart
/// class MyDemo extends StatelessWidget {
325 326 327
///   const MyDemo({super.key});
///
///   static const List<Tab> myTabs = <Tab>[
328 329
///     Tab(text: 'LEFT'),
///     Tab(text: 'RIGHT'),
330 331 332 333
///   ];
///
///   @override
///   Widget build(BuildContext context) {
334
///     return DefaultTabController(
335
///       length: myTabs.length,
336 337
///       child: Scaffold(
///         appBar: AppBar(
338
///           bottom: const TabBar(
339 340 341
///             tabs: myTabs,
///           ),
///         ),
342
///         body: TabBarView(
343
///           children: myTabs.map((Tab tab) {
344
///             final String label = tab.text!.toLowerCase();
345 346 347 348 349 350
///             return Center(
///               child: Text(
///                 'This is the $label tab',
///                 style: const TextStyle(fontSize: 36),
///               ),
///             );
351 352 353 354 355 356 357
///           }).toList(),
///         ),
///       ),
///     );
///   }
/// }
/// ```
Hans Muller's avatar
Hans Muller committed
358
class DefaultTabController extends StatefulWidget {
359 360
  /// Creates a default tab controller for the given [child] widget.
  ///
361 362
  /// The [length] argument is typically greater than one. The [length] must
  /// match [TabBar.tabs]'s and [TabBarView.children]'s length.
363
  const DefaultTabController({
364
    super.key,
365
    required this.length,
366
    this.initialIndex = 0,
367
    required this.child,
368
    this.animationDuration,
369
  }) : assert(length >= 0),
370
       assert(length == 0 || (initialIndex >= 0 && initialIndex < length));
Hans Muller's avatar
Hans Muller committed
371

372 373 374 375
  /// 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
376 377 378
  final int length;

  /// The initial index of the selected tab.
379 380
  ///
  /// Defaults to zero.
Hans Muller's avatar
Hans Muller committed
381 382
  final int initialIndex;

383 384 385 386 387
  /// Controls the duration of DefaultTabController and TabBarView animations.
  ///
  /// Defaults to kTabScrollDuration.
  final Duration? animationDuration;

388 389 390 391
  /// The widget below this widget in the tree.
  ///
  /// Typically a [Scaffold] whose [AppBar] includes a [TabBar].
  ///
392
  /// {@macro flutter.widgets.ProxyWidget.child}
Hans Muller's avatar
Hans Muller committed
393 394
  final Widget child;

395 396
  /// The closest instance of [DefaultTabController] that encloses the given
  /// context, or null if none is found.
Hans Muller's avatar
Hans Muller committed
397
  ///
398
  /// {@tool snippet} Typical usage is as follows:
Hans Muller's avatar
Hans Muller committed
399 400
  ///
  /// ```dart
401
  /// TabController? controller = DefaultTabController.maybeOf(context);
Hans Muller's avatar
Hans Muller committed
402
  /// ```
403
  /// {@end-tool}
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 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
  ///
  /// 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
454 455 456
  }

  @override
457
  State<DefaultTabController> createState() => _DefaultTabControllerState();
Hans Muller's avatar
Hans Muller committed
458 459 460
}

class _DefaultTabControllerState extends State<DefaultTabController> with SingleTickerProviderStateMixin {
461
  late TabController _controller;
Hans Muller's avatar
Hans Muller committed
462 463 464 465

  @override
  void initState() {
    super.initState();
466
    _controller = TabController(
Hans Muller's avatar
Hans Muller committed
467
      vsync: this,
468 469
      length: widget.length,
      initialIndex: widget.initialIndex,
470
      animationDuration: widget.animationDuration,
Hans Muller's avatar
Hans Muller committed
471 472 473 474 475 476 477 478 479 480 481
    );
  }

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

  @override
  Widget build(BuildContext context) {
482
    return _TabControllerScope(
Hans Muller's avatar
Hans Muller committed
483 484
      controller: _controller,
      enabled: TickerMode.of(context),
485
      child: widget.child,
Hans Muller's avatar
Hans Muller committed
486 487
    );
  }
488 489 490 491 492 493 494

  @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.
495
      int? newIndex;
496 497 498 499 500
      int previousIndex = _controller.previousIndex;
      if (_controller.index >= widget.length) {
        newIndex = math.max(0, widget.length - 1);
        previousIndex = _controller.index;
      }
501
      _controller = _controller._copyWithAndDispose(
502
        length: widget.length,
503
        animationDuration: widget.animationDuration,
504 505 506 507
        index: newIndex,
        previousIndex: previousIndex,
      );
    }
508 509

    if (oldWidget.animationDuration != widget.animationDuration) {
510
      _controller = _controller._copyWithAndDispose(
511 512 513 514 515 516
        length: widget.length,
        animationDuration: widget.animationDuration,
        index: _controller.index,
        previousIndex: _controller.previousIndex,
      );
    }
517
  }
Hans Muller's avatar
Hans Muller committed
518
}