tab_controller.dart 16.2 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).
Kate Lovett's avatar
Kate Lovett committed
104 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,
       );
Hans Muller's avatar
Hans Muller committed
118

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


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

Hans Muller's avatar
Hans Muller committed
158 159 160 161 162 163 164 165
  /// 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.
166
  ///
167
  /// If this [TabController] was disposed, then return null.
168 169
  Animation<double>? get animation => _animationController?.view;
  AnimationController? _animationController;
Hans Muller's avatar
Hans Muller committed
170

171 172 173 174 175 176
  /// Controls the duration of TabController and TabBarView animations.
  ///
  /// Defaults to kTabScrollDuration.
  Duration get animationDuration => _animationDuration;
  final Duration _animationDuration;

177 178 179 180
  /// 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
181 182
  final int length;

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

211 212 213 214
  /// 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
215 216
  ///
  /// To change the currently selected tab and play the [animation] use [animateTo].
217 218 219
  ///
  /// 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
220 221 222 223 224 225
  int get index => _index;
  int _index;
  set index(int value) {
    _changeIndex(value);
  }

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

232 233 234 235 236 237
  /// 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
238 239 240 241 242 243 244 245
  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.
246 247
  void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) {
    _changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
Hans Muller's avatar
Hans Muller committed
248 249
  }

250 251 252
  /// 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
253 254 255 256 257
  ///
  /// 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.
258
  double get offset => _animationController!.value - _index.toDouble();
259 260
  set offset(double value) {
    assert(value >= -1.0 && value <= 1.0);
Hans Muller's avatar
Hans Muller committed
261
    assert(!indexIsChanging);
262
    if (value == offset) {
Hans Muller's avatar
Hans Muller committed
263
      return;
264
    }
265
    _animationController!.value = value + _index.toDouble();
Hans Muller's avatar
Hans Muller committed
266 267 268 269
  }

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

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

  final TabController controller;
  final bool enabled;

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

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

356 357 358 359
  /// 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
360 361 362
  final int length;

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

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

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

379 380
  /// The closest instance of [DefaultTabController] that encloses the given
  /// context, or null if none is found.
Hans Muller's avatar
Hans Muller committed
381
  ///
382
  /// {@tool snippet} Typical usage is as follows:
Hans Muller's avatar
Hans Muller committed
383 384
  ///
  /// ```dart
385
  /// TabController? controller = DefaultTabController.maybeOf(context);
Hans Muller's avatar
Hans Muller committed
386
  /// ```
387
  /// {@end-tool}
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 437
  ///
  /// 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
438 439 440
  }

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

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

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

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

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

  @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.
479
      int? newIndex;
480 481 482 483 484 485 486
      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,
487
        animationDuration: widget.animationDuration,
488 489 490 491
        index: newIndex,
        previousIndex: previousIndex,
      );
    }
492 493 494 495 496 497 498 499 500

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