diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index 53705ebfb19..dbaf036bcfc 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -34,6 +34,8 @@ import 'theme.dart'; const double _kLeadingWidth = kToolbarHeight; // So the leading button is square. const double _kMaxTitleTextScaleFactor = 1.34; // TODO(perc): Add link to Material spec when available, https://github.com/flutter/flutter/issues/58769. +enum _SliverAppVariant { small, medium, large } + // Bottom justify the toolbarHeight child which may overflow the top. class _ToolbarContainerLayout extends SingleChildLayoutDelegate { const _ToolbarContainerLayout(this.toolbarHeight); @@ -1191,7 +1193,8 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { required this.titleTextStyle, required this.systemOverlayStyle, required this.forceMaterialTransparency, - required this.clipBehavior + required this.clipBehavior, + required this.variant, }) : assert(primary || topPadding == 0.0), _bottomHeight = bottom?.preferredSize.height ?? 0.0; @@ -1228,6 +1231,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { final double _bottomHeight; final bool forceMaterialTransparency; final Clip? clipBehavior; + final _SliverAppVariant variant; @override double get minExtent => collapsedHeight; @@ -1258,6 +1262,17 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { final double toolbarOpacity = !pinned || isPinnedWithOpacityFade ? clampDouble(visibleToolbarHeight / (toolbarHeight ?? kToolbarHeight), 0.0, 1.0) : 1.0; + final Widget? effectiveTitle; + if (variant == _SliverAppVariant.small) { + effectiveTitle = title; + } else { + effectiveTitle = AnimatedOpacity( + opacity: isScrolledUnder ? 1 : 0, + duration: const Duration(milliseconds: 500), + curve: const Cubic(0.2, 0.0, 0.0, 1.0), + child: title, + ); + } final Widget appBar = FlexibleSpaceBar.createSettings( minExtent: minExtent, @@ -1269,7 +1284,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { clipBehavior: clipBehavior, leading: leading, automaticallyImplyLeading: automaticallyImplyLeading, - title: title, + title: effectiveTitle, actions: actions, flexibleSpace: (title == null && flexibleSpace != null && !excludeHeaderSemantics) ? Semantics( @@ -1474,7 +1489,11 @@ class SliverAppBar extends StatefulWidget { this.clipBehavior, }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), assert(stretchTriggerOffset > 0.0), - assert(collapsedHeight == null || collapsedHeight >= toolbarHeight, 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].'); + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.small; /// Creates a Material Design medium top app bar that can be placed /// in a [CustomScrollView]. @@ -1499,87 +1518,50 @@ class SliverAppBar extends StatefulWidget { /// * [SliverAppBar.large], for a large top app bar. /// * https://m3.material.io/components/top-app-bar/overview, the Material 3 /// app bar specification. - factory SliverAppBar.medium({ - Key? key, - Widget? leading, - bool automaticallyImplyLeading = true, - Widget? title, - List? actions, - Widget? flexibleSpace, - PreferredSizeWidget? bottom, - double? elevation, - double? scrolledUnderElevation, - Color? shadowColor, - Color? surfaceTintColor, - bool forceElevated = false, - Color? backgroundColor, - Color? foregroundColor, - IconThemeData? iconTheme, - IconThemeData? actionsIconTheme, - bool primary = true, - bool? centerTitle, - bool excludeHeaderSemantics = false, - double? titleSpacing, - double? collapsedHeight, - double? expandedHeight, - bool floating = false, - bool pinned = true, - bool snap = false, - bool stretch = false, - double stretchTriggerOffset = 100.0, - AsyncCallback? onStretchTrigger, - ShapeBorder? shape, - double toolbarHeight = _MediumScrollUnderFlexibleConfig.collapsedHeight, - double? leadingWidth, - TextStyle? toolbarTextStyle, - TextStyle? titleTextStyle, - SystemUiOverlayStyle? systemOverlayStyle, - }) { - return SliverAppBar( - key: key, - leading: leading, - automaticallyImplyLeading: automaticallyImplyLeading, - flexibleSpace: flexibleSpace ?? _ScrollUnderFlexibleSpace( - hasLeading: leading != null, - title: title, - actions: actions, - foregroundColor: foregroundColor, - variant: _ScrollUnderFlexibleVariant.medium, - centerCollapsedTitle: centerTitle, - primary: primary, - leadingWidth: leadingWidth, - titleSpacing: titleSpacing, - ), - bottom: bottom, - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - shadowColor: shadowColor, - surfaceTintColor: surfaceTintColor, - forceElevated: forceElevated, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, - iconTheme: iconTheme, - actionsIconTheme: actionsIconTheme, - primary: primary, - centerTitle: centerTitle, - excludeHeaderSemantics: excludeHeaderSemantics, - titleSpacing: titleSpacing, - collapsedHeight: collapsedHeight ?? _MediumScrollUnderFlexibleConfig.collapsedHeight, - expandedHeight: expandedHeight ?? _MediumScrollUnderFlexibleConfig.expandedHeight, - floating: floating, - pinned: pinned, - snap: snap, - stretch: stretch, - stretchTriggerOffset: stretchTriggerOffset, - onStretchTrigger: onStretchTrigger, - shape: shape, - toolbarHeight: toolbarHeight, - leadingWidth: leadingWidth, - toolbarTextStyle: toolbarTextStyle, - titleTextStyle: titleTextStyle, - systemOverlayStyle: systemOverlayStyle, - ); - } + const SliverAppBar.medium({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.forceElevated = false, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.collapsedHeight, + this.expandedHeight, + this.floating = false, + this.pinned = true, + this.snap = false, + this.stretch = false, + this.stretchTriggerOffset = 100.0, + this.onStretchTrigger, + this.shape, + this.toolbarHeight = _MediumScrollUnderFlexibleConfig.collapsedHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.clipBehavior, + }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), + assert(stretchTriggerOffset > 0.0), + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.medium; /// Creates a Material Design large top app bar that can be placed /// in a [CustomScrollView]. @@ -1604,87 +1586,50 @@ class SliverAppBar extends StatefulWidget { /// * [SliverAppBar.medium], for a medium top app bar. /// * https://m3.material.io/components/top-app-bar/overview, the Material 3 /// app bar specification. - factory SliverAppBar.large({ - Key? key, - Widget? leading, - bool automaticallyImplyLeading = true, - Widget? title, - List? actions, - Widget? flexibleSpace, - PreferredSizeWidget? bottom, - double? elevation, - double? scrolledUnderElevation, - Color? shadowColor, - Color? surfaceTintColor, - bool forceElevated = false, - Color? backgroundColor, - Color? foregroundColor, - IconThemeData? iconTheme, - IconThemeData? actionsIconTheme, - bool primary = true, - bool? centerTitle, - bool excludeHeaderSemantics = false, - double? titleSpacing, - double? collapsedHeight, - double? expandedHeight, - bool floating = false, - bool pinned = true, - bool snap = false, - bool stretch = false, - double stretchTriggerOffset = 100.0, - AsyncCallback? onStretchTrigger, - ShapeBorder? shape, - double toolbarHeight = _LargeScrollUnderFlexibleConfig.collapsedHeight, - double? leadingWidth, - TextStyle? toolbarTextStyle, - TextStyle? titleTextStyle, - SystemUiOverlayStyle? systemOverlayStyle, - }) { - return SliverAppBar( - key: key, - leading: leading, - automaticallyImplyLeading: automaticallyImplyLeading, - flexibleSpace: flexibleSpace ?? _ScrollUnderFlexibleSpace( - hasLeading: leading != null, - title: title, - actions: actions, - foregroundColor: foregroundColor, - variant: _ScrollUnderFlexibleVariant.large, - centerCollapsedTitle: centerTitle, - primary: primary, - leadingWidth: leadingWidth, - titleSpacing: titleSpacing, - ), - bottom: bottom, - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - shadowColor: shadowColor, - surfaceTintColor: surfaceTintColor, - forceElevated: forceElevated, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, - iconTheme: iconTheme, - actionsIconTheme: actionsIconTheme, - primary: primary, - centerTitle: centerTitle, - excludeHeaderSemantics: excludeHeaderSemantics, - titleSpacing: titleSpacing, - collapsedHeight: collapsedHeight ?? _LargeScrollUnderFlexibleConfig.collapsedHeight, - expandedHeight: expandedHeight ?? _LargeScrollUnderFlexibleConfig.expandedHeight, - floating: floating, - pinned: pinned, - snap: snap, - stretch: stretch, - stretchTriggerOffset: stretchTriggerOffset, - onStretchTrigger: onStretchTrigger, - shape: shape, - toolbarHeight: toolbarHeight, - leadingWidth: leadingWidth, - toolbarTextStyle: toolbarTextStyle, - titleTextStyle: titleTextStyle, - systemOverlayStyle: systemOverlayStyle, - ); - } + const SliverAppBar.large({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.forceElevated = false, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.collapsedHeight, + this.expandedHeight, + this.floating = false, + this.pinned = true, + this.snap = false, + this.stretch = false, + this.stretchTriggerOffset = 100.0, + this.onStretchTrigger, + this.shape, + this.toolbarHeight = _LargeScrollUnderFlexibleConfig.collapsedHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.clipBehavior, + }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), + assert(stretchTriggerOffset > 0.0), + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.large; /// {@macro flutter.material.appbar.leading} /// @@ -1941,6 +1886,8 @@ class SliverAppBar extends StatefulWidget { /// {@macro flutter.material.Material.clipBehavior} final Clip? clipBehavior; + final _SliverAppVariant _variant; + @override State createState() => _SliverAppBarState(); } @@ -2004,6 +1951,41 @@ class _SliverAppBarState extends State with TickerProviderStateMix final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null) ? (widget.collapsedHeight ?? 0.0) + bottomHeight + topPadding : (widget.collapsedHeight ?? widget.toolbarHeight) + bottomHeight + topPadding; + final double? effectiveExpandedHeight; + final double effectiveCollapsedHeight; + final Widget? effectiveFlexibleSpace; + switch (widget._variant) { + case _SliverAppVariant.small: + effectiveExpandedHeight = widget.expandedHeight; + effectiveCollapsedHeight = collapsedHeight; + effectiveFlexibleSpace = widget.flexibleSpace; + case _SliverAppVariant.medium: + effectiveExpandedHeight = widget.expandedHeight + ?? _MediumScrollUnderFlexibleConfig.expandedHeight + bottomHeight; + effectiveCollapsedHeight = widget.collapsedHeight + ?? topPadding + _MediumScrollUnderFlexibleConfig.collapsedHeight + bottomHeight; + effectiveFlexibleSpace = widget.flexibleSpace ?? _ScrollUnderFlexibleSpace( + title: widget.title, + foregroundColor: widget.foregroundColor, + variant: _ScrollUnderFlexibleVariant.medium, + primary: widget.primary, + titleTextStyle: widget.titleTextStyle, + bottomHeight: bottomHeight, + ); + case _SliverAppVariant.large: + effectiveExpandedHeight = widget.expandedHeight + ?? _LargeScrollUnderFlexibleConfig.expandedHeight + bottomHeight; + effectiveCollapsedHeight = widget.collapsedHeight + ?? topPadding + _LargeScrollUnderFlexibleConfig.collapsedHeight + bottomHeight; + effectiveFlexibleSpace = widget.flexibleSpace ?? _ScrollUnderFlexibleSpace( + title: widget.title, + foregroundColor: widget.foregroundColor, + variant: _ScrollUnderFlexibleVariant.large, + primary: widget.primary, + titleTextStyle: widget.titleTextStyle, + bottomHeight: bottomHeight, + ); + } return MediaQuery.removePadding( context: context, @@ -2017,7 +1999,7 @@ class _SliverAppBarState extends State with TickerProviderStateMix automaticallyImplyLeading: widget.automaticallyImplyLeading, title: widget.title, actions: widget.actions, - flexibleSpace: widget.flexibleSpace, + flexibleSpace: effectiveFlexibleSpace, bottom: widget.bottom, elevation: widget.elevation, scrolledUnderElevation: widget.scrolledUnderElevation, @@ -2032,8 +2014,8 @@ class _SliverAppBarState extends State with TickerProviderStateMix centerTitle: widget.centerTitle, excludeHeaderSemantics: widget.excludeHeaderSemantics, titleSpacing: widget.titleSpacing, - expandedHeight: widget.expandedHeight, - collapsedHeight: collapsedHeight, + expandedHeight: effectiveExpandedHeight, + collapsedHeight: effectiveCollapsedHeight, topPadding: topPadding, floating: widget.floating, pinned: widget.pinned, @@ -2048,6 +2030,7 @@ class _SliverAppBarState extends State with TickerProviderStateMix systemOverlayStyle: widget.systemOverlayStyle, forceMaterialTransparency: widget.forceMaterialTransparency, clipBehavior: widget.clipBehavior, + variant: widget._variant, ), ), ); @@ -2098,35 +2081,30 @@ enum _ScrollUnderFlexibleVariant { medium, large } class _ScrollUnderFlexibleSpace extends StatelessWidget { const _ScrollUnderFlexibleSpace({ - required this.hasLeading, this.title, - this.actions, this.foregroundColor, required this.variant, - this.centerCollapsedTitle, this.primary = true, - this.leadingWidth, - this.titleSpacing, + this.titleTextStyle, + required this.bottomHeight, }); - final bool hasLeading; final Widget? title; - final List? actions; final Color? foregroundColor; final _ScrollUnderFlexibleVariant variant; - final bool? centerCollapsedTitle; final bool primary; - final double? leadingWidth; - final double? titleSpacing; + final TextStyle? titleTextStyle; + final double bottomHeight; @override Widget build(BuildContext context) { late final ThemeData theme = Theme.of(context); late final AppBarTheme appBarTheme = AppBarTheme.of(context); + final AppBarTheme defaults = theme.useMaterial3 ? _AppBarDefaultsM3(context) : _AppBarDefaultsM2(context); final FlexibleSpaceBarSettings settings = context.dependOnInheritedWidgetOfExactType()!; final double topPadding = primary ? MediaQuery.viewPaddingOf(context).top : 0; - final double collapsedHeight = settings.minExtent - topPadding; - final double scrollUnderHeight = settings.maxExtent - settings.minExtent; + final double collapsedHeight = settings.minExtent - topPadding - bottomHeight; + final double scrollUnderHeight = settings.maxExtent - settings.minExtent + bottomHeight; final _ScrollUnderFlexibleConfig config; switch (variant) { case _ScrollUnderFlexibleVariant.medium: @@ -2135,71 +2113,46 @@ class _ScrollUnderFlexibleSpace extends StatelessWidget { config = _LargeScrollUnderFlexibleConfig(context); } - late final Widget? collapsedTitle; late final Widget? expandedTitle; if (title != null) { - collapsedTitle = config.collapsedTextStyle != null - ? DefaultTextStyle( - style: config.collapsedTextStyle!.copyWith(color: foregroundColor ?? appBarTheme.foregroundColor), - child: title!, - ) - : title; + final TextStyle? expandedTextStyle = titleTextStyle + ?? appBarTheme.titleTextStyle + ?? config.expandedTextStyle?.copyWith( + color: foregroundColor ?? appBarTheme.foregroundColor ?? defaults.foregroundColor, + ); expandedTitle = config.expandedTextStyle != null ? DefaultTextStyle( - style: config.expandedTextStyle!.copyWith(color: foregroundColor ?? appBarTheme.foregroundColor), + style: expandedTextStyle!, child: title!, ) : title; } - late final bool centerTitle; - { - bool platformCenter() { - switch (theme.platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - return false; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - return true; - } + EdgeInsetsGeometry expandedTitlePadding() { + final EdgeInsets padding = config.expandedTitlePadding!.resolve(Directionality.of(context)); + if (bottomHeight > 0) { + return EdgeInsets.fromLTRB(padding.left, 0, padding.right, bottomHeight); } - centerTitle = centerCollapsedTitle ?? appBarTheme.centerTitle ?? platformCenter(); + if (theme.useMaterial3) { + final TextStyle textStyle = config.expandedTextStyle!; + // Substract the bottom line height from the bottom padding. + // TODO(tahatesser): Figure out why this is done. + // https://github.com/flutter/flutter/issues/120672 + final double adjustBottomPadding = padding.bottom + - (textStyle.fontSize! * textStyle.height! - textStyle.fontSize!) / 2; + return EdgeInsets.fromLTRB( + padding.left, + 0, + padding.right, + adjustBottomPadding, + ); + } + return padding; } - - EdgeInsetsGeometry effectiveCollapsedTitlePadding = EdgeInsets.zero; - if (hasLeading && leadingWidth == null) { - effectiveCollapsedTitlePadding = centerTitle - ? config.collapsedCenteredTitlePadding! - : config.collapsedTitlePadding!; - } else if (hasLeading && leadingWidth != null) { - effectiveCollapsedTitlePadding = EdgeInsetsDirectional.only(start: leadingWidth!); - } - final bool isCollapsed = settings.isScrolledUnder ?? false; return Column( children: [ Padding( - padding: EdgeInsets.only(top: topPadding), - child: Container( - height: collapsedHeight, - padding: effectiveCollapsedTitlePadding, - child: NavigationToolbar( - centerMiddle: centerTitle, - middleSpacing: titleSpacing ?? appBarTheme.titleSpacing ?? NavigationToolbar.kMiddleSpacing, - middle: AnimatedOpacity( - opacity: isCollapsed ? 1 : 0, - duration: const Duration(milliseconds: 500), - curve: const Cubic(0.2, 0.0, 0.0, 1.0), - child: collapsedTitle, - ), - trailing: actions != null ? Row( - mainAxisSize: MainAxisSize.min, - children: actions!, - ) : null, - ), - ), + padding: EdgeInsets.only(top: collapsedHeight + topPadding), ), Flexible( child: ClipRect( @@ -2209,7 +2162,7 @@ class _ScrollUnderFlexibleSpace extends StatelessWidget { alignment: Alignment.bottomLeft, child: Container( alignment: AlignmentDirectional.bottomStart, - padding: config.expandedTitlePadding, + padding: expandedTitlePadding(), child: expandedTitle, ), ), @@ -2223,8 +2176,6 @@ class _ScrollUnderFlexibleSpace extends StatelessWidget { mixin _ScrollUnderFlexibleConfig { TextStyle? get collapsedTextStyle; TextStyle? get expandedTextStyle; - EdgeInsetsGeometry? get collapsedTitlePadding; - EdgeInsetsGeometry? get collapsedCenteredTitlePadding; EdgeInsetsGeometry? get expandedTitlePadding; } @@ -2332,12 +2283,6 @@ class _MediumScrollUnderFlexibleConfig with _ScrollUnderFlexibleConfig { TextStyle? get expandedTextStyle => _textTheme.headlineSmall?.apply(color: _colors.onSurface); - @override - EdgeInsetsGeometry? get collapsedTitlePadding => const EdgeInsetsDirectional.only(start: 40); - - @override - EdgeInsetsGeometry? get collapsedCenteredTitlePadding => const EdgeInsetsDirectional.only(start: 40); - @override EdgeInsetsGeometry? get expandedTitlePadding => const EdgeInsets.fromLTRB(16, 0, 16, 20); } @@ -2361,12 +2306,6 @@ class _LargeScrollUnderFlexibleConfig with _ScrollUnderFlexibleConfig { TextStyle? get expandedTextStyle => _textTheme.headlineMedium?.apply(color: _colors.onSurface); - @override - EdgeInsetsGeometry? get collapsedTitlePadding => const EdgeInsetsDirectional.only(start: 40); - - @override - EdgeInsetsGeometry? get collapsedCenteredTitlePadding => const EdgeInsetsDirectional.only(start: 40); - @override EdgeInsetsGeometry? get expandedTitlePadding => const EdgeInsets.fromLTRB(16, 0, 16, 28); } diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index 8c43d4a0b8b..0d87fc0041b 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -1086,16 +1086,18 @@ void main() { }); testWidgets('SliverAppBar.medium defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); const double collapsedAppBarHeight = 64; const double expandedAppBarHeight = 112; await tester.pumpWidget(MaterialApp( + theme: theme, home: Scaffold( body: CustomScrollView( primary: true, slivers: [ - SliverAppBar.medium( - title: const Text('AppBar Title'), + const SliverAppBar.medium( + title: Text('AppBar Title'), ), SliverToBoxAdapter( child: Container( @@ -1109,21 +1111,20 @@ void main() { )); final ScrollController controller = primaryScrollController(tester); - // There are two widgets for the title. The first is the title on the main - // row with the icons. It is transparent when the app bar is expanded, and - // opaque when it is collapsed. The second title is a larger version that is - // shown at the bottom when the app bar is expanded. It scrolls under the - // main row until it is completely hidden and then the first title is faded - // in. - final Finder collapsedTitle = find.text('AppBar Title').first; - final Finder collapsedTitleOpacity = find.ancestor( - of: collapsedTitle, - matching: find.byType(AnimatedOpacity), - ); - final Finder expandedTitle = find.text('AppBar Title').last; + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), + ).first; + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), ); // Default, fully expanded app bar. @@ -1133,6 +1134,17 @@ void main() { expect(tester.widget(collapsedTitleOpacity).opacity, 0); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset.dx, 16.0); + expect(titleOffset.dy, closeTo(96.0, 0.1)); + + // Test the expanded title default color. + expect( + tester.renderObject(expandedTitle).text.style!.color, + theme.colorScheme.onSurface, + ); + // Scroll the expanded app bar partially out of view. controller.jumpTo(45); await tester.pump(); @@ -1159,16 +1171,18 @@ void main() { }); testWidgets('SliverAppBar.large defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); const double collapsedAppBarHeight = 64; const double expandedAppBarHeight = 152; await tester.pumpWidget(MaterialApp( + theme: theme, home: Scaffold( body: CustomScrollView( primary: true, slivers: [ - SliverAppBar.large( - title: const Text('AppBar Title'), + const SliverAppBar.large( + title: Text('AppBar Title'), ), SliverToBoxAdapter( child: Container( @@ -1182,21 +1196,20 @@ void main() { )); final ScrollController controller = primaryScrollController(tester); - // There are two widgets for the title. The first is the title on the main - // row with the icons. It is transparent when the app bar is expanded, and - // opaque when it is collapsed. The second title is a larger version that is - // shown at the bottom when the app bar is expanded. It scrolls under the - // main row until it is completely hidden and then the first title is faded - // in. - final Finder collapsedTitle = find.text('AppBar Title').first; - final Finder collapsedTitleOpacity = find.ancestor( - of: collapsedTitle, - matching: find.byType(AnimatedOpacity), - ); - final Finder expandedTitle = find.text('AppBar Title').last; + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), + ).first; + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), ); // Default, fully expanded app bar. @@ -1206,6 +1219,19 @@ void main() { expect(tester.widget(collapsedTitleOpacity).opacity, 0); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset.dx, 16.0); + expect(titleOffset.dy, closeTo(128.0, 0.1)); + + + // Test the expanded title default color. + expect( + tester.renderObject(expandedTitle).text.style!.color, + theme.colorScheme.onSurface, + ); + + // Scroll the expanded app bar partially out of view. controller.jumpTo(45); await tester.pump(); @@ -4049,6 +4075,7 @@ void main() { onPressed: () {}, ), title: const Text(title, maxLines: 1), + centerTitle: true, actions: const [ Icon(Icons.search), Icon(Icons.sort), @@ -4073,11 +4100,11 @@ void main() { await tester.pumpAndSettle(); final Offset leadingOffset = tester.getTopRight(find.byIcon(Icons.menu)); - Offset titleOffset = tester.getTopLeft(find.text(title).first); + Offset titleOffset = tester.getTopLeft(find.text(title).last); // The title widget should be to the right of the leading widget. expect(titleOffset.dx, greaterThan(leadingOffset.dx)); - titleOffset = tester.getTopRight(find.text(title).first); + titleOffset = tester.getTopRight(find.text(title).last); final Offset searchOffset = tester.getTopLeft(find.byIcon(Icons.search)); // The title widget should be to the left of the search icon. expect(titleOffset.dx, lessThan(searchOffset.dx)); @@ -4100,6 +4127,7 @@ void main() { onPressed: () {}, ), title: const Text(title, maxLines: 1), + centerTitle: true, actions: const [ Icon(Icons.search), Icon(Icons.sort), @@ -4124,11 +4152,11 @@ void main() { await tester.pumpAndSettle(); final Offset leadingOffset = tester.getTopRight(find.byIcon(Icons.menu)); - Offset titleOffset = tester.getTopLeft(find.text(title).first); + Offset titleOffset = tester.getTopLeft(find.text(title).last); // The title widget should be to the right of the leading widget. expect(titleOffset.dx, greaterThan(leadingOffset.dx)); - titleOffset = tester.getTopRight(find.text(title).first); + titleOffset = tester.getTopRight(find.text(title).last); final Offset searchOffset = tester.getTopLeft(find.byIcon(Icons.search)); // The title widget should be to the left of the search icon. expect(titleOffset.dx, lessThan(searchOffset.dx)); @@ -4144,18 +4172,21 @@ void main() { body: CustomScrollView( primary: true, slivers: [ - SliverAppBar.medium( - centerTitle: centerTitle, - leading: IconButton( - icon: const Icon(Icons.menu), - onPressed: () {}, + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.medium( + leading: IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu), + ), + title: const Text(title, maxLines: 1), + centerTitle: centerTitle, + titleSpacing: titleSpacing, + actions: [ + IconButton(onPressed: () {}, icon: const Icon(Icons.sort)), + IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + ], ), - title: const Text(title, maxLines: 1), - titleSpacing: titleSpacing, - actions: const [ - Icon(Icons.sort), - Icon(Icons.more_vert), - ], ), SliverToBoxAdapter( child: Container( @@ -4171,55 +4202,57 @@ void main() { await tester.pumpWidget(buildWidget()); + final Finder collapsedTitle = find.text(title).last; + // Scroll to collapse the SliverAppBar. ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); // By default, title widget should be to the right of the // leading widget and title spacing should be respected. - Offset titleOffset = tester.getTopLeft(find.text(title).first); - Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - expect(titleOffset.dx, iconOffset.dx + titleSpacing); + Offset titleOffset = tester.getTopLeft(collapsedTitle); + Offset iconButtonOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconButtonOffset.dx + titleSpacing); await tester.pumpWidget(buildWidget(centerTitle: true)); // Scroll to collapse the SliverAppBar. controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); // By default, title widget should be to the left of the first - // leading widget and title spacing should be respected. - titleOffset = tester.getTopRight(find.text(title).first); - iconOffset = tester.getTopLeft(find.byIcon(Icons.sort)); - expect(titleOffset.dx, iconOffset.dx - titleSpacing); + // trailing widget and title spacing should be respected. + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx - titleSpacing); // Test custom title spacing, set to 0.0. await tester.pumpWidget(buildWidget(titleSpacing: 0.0)); // Scroll to collapse the SliverAppBar. controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); // The title widget should be to the right of the leading // widget with no spacing. - titleOffset = tester.getTopLeft(find.text(title).first); - iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - expect(titleOffset.dx, iconOffset.dx); + titleOffset = tester.getTopLeft(collapsedTitle); + iconButtonOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconButtonOffset.dx); // Set centerTitle to true so the end of the title can reach // the action widgets. await tester.pumpWidget(buildWidget(titleSpacing: 0.0, centerTitle: true)); // Scroll to collapse the SliverAppBar. controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); // The title widget should be to the left of the first // leading widget with no spacing. - titleOffset = tester.getTopRight(find.text(title).first); - iconOffset = tester.getTopLeft(find.byIcon(Icons.sort)); - expect(titleOffset.dx, iconOffset.dx); + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx); }); testWidgets('SliverAppBar.large respects title spacing', (WidgetTester tester) async { @@ -4232,18 +4265,21 @@ void main() { body: CustomScrollView( primary: true, slivers: [ - SliverAppBar.large( - centerTitle: centerTitle, - leading: IconButton( - icon: const Icon(Icons.menu), - onPressed: () {}, + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.large( + leading: IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu), + ), + title: const Text(title, maxLines: 1), + centerTitle: centerTitle, + titleSpacing: titleSpacing, + actions: [ + IconButton(onPressed: () {}, icon: const Icon(Icons.sort)), + IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + ], ), - title: const Text(title, maxLines: 1), - titleSpacing: titleSpacing, - actions: const [ - Icon(Icons.sort), - Icon(Icons.more_vert), - ], ), SliverToBoxAdapter( child: Container( @@ -4259,61 +4295,63 @@ void main() { await tester.pumpWidget(buildWidget()); + final Finder collapsedTitle = find.text(title).last; + // Scroll to collapse the SliverAppBar. ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(160); await tester.pumpAndSettle(); // By default, title widget should be to the right of the leading // widget and title spacing should be respected. - Offset titleOffset = tester.getTopLeft(find.text(title).first); - Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - expect(titleOffset.dx, iconOffset.dx + titleSpacing); + Offset titleOffset = tester.getTopLeft(collapsedTitle); + Offset iconButtonOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconButtonOffset.dx + titleSpacing); await tester.pumpWidget(buildWidget(centerTitle: true)); // Scroll to collapse the SliverAppBar. controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(160); await tester.pumpAndSettle(); - // By default, title widget should be to the right of the + // By default, title widget should be to the left of the // leading widget and title spacing should be respected. - titleOffset = tester.getTopRight(find.text(title).first); - iconOffset = tester.getTopLeft(find.byIcon(Icons.sort)); - expect(titleOffset.dx, iconOffset.dx - titleSpacing); + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx - titleSpacing); // Test custom title spacing, set to 0.0. await tester.pumpWidget(buildWidget(titleSpacing: 0.0)); controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(160); await tester.pumpAndSettle(); // The title widget should be to the right of the leading // widget with no spacing. - titleOffset = tester.getTopLeft(find.text(title).first); - iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - expect(titleOffset.dx, iconOffset.dx); + titleOffset = tester.getTopLeft(collapsedTitle); + iconButtonOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconButtonOffset.dx); // Set centerTitle to true so the end of the title can reach // the action widgets. await tester.pumpWidget(buildWidget(titleSpacing: 0.0, centerTitle: true)); // Scroll to collapse the SliverAppBar. controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(160); await tester.pumpAndSettle(); // The title widget should be to the left of the first // leading widget with no spacing. - titleOffset = tester.getTopRight(find.text(title).first); - iconOffset = tester.getTopLeft(find.byIcon(Icons.sort)); - expect(titleOffset.dx, iconOffset.dx); + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx); }); testWidgets( 'SliverAppBar.medium without the leading widget updates collapsed title padding', - (WidgetTester widgetTester) async { + (WidgetTester tester) async { const String title = 'Medium SliverAppBar Title'; - const double leadingPadding = 40.0; + const double leadingPadding = 56.0; const double titleSpacing = 16.0; Widget buildWidget({ bool showLeading = true }) { @@ -4323,6 +4361,7 @@ void main() { primary: true, slivers: [ SliverAppBar.medium( + automaticallyImplyLeading: false, leading: showLeading ? IconButton( icon: const Icon(Icons.menu), @@ -4343,36 +4382,38 @@ void main() { ); } - await widgetTester.pumpWidget(buildWidget()); + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; // Scroll to collapse the SliverAppBar. - ScrollController controller = primaryScrollController(widgetTester); + ScrollController controller = primaryScrollController(tester); controller.jumpTo(45); - await widgetTester.pumpAndSettle(); + await tester.pumpAndSettle(); // If the leading widget is present, the title widget should be to the // right of the leading widget and title spacing should be respected. - Offset titleOffset = widgetTester.getTopLeft(find.text(title).first); + Offset titleOffset = tester.getTopLeft(collapsedTitle); expect(titleOffset.dx, leadingPadding + titleSpacing); // Hide the leading widget. - await widgetTester.pumpWidget(buildWidget(showLeading: false)); + await tester.pumpWidget(buildWidget(showLeading: false)); // Scroll to collapse the SliverAppBar. - controller = primaryScrollController(widgetTester); + controller = primaryScrollController(tester); controller.jumpTo(45); - await widgetTester.pumpAndSettle(); + await tester.pumpAndSettle(); // If the leading widget is not present, the title widget will // only have the default title spacing. - titleOffset = widgetTester.getTopLeft(find.text(title).first); + titleOffset = tester.getTopLeft(collapsedTitle); expect(titleOffset.dx, titleSpacing); }); testWidgets( 'SliverAppBar.large without the leading widget updates collapsed title padding', - (WidgetTester widgetTester) async { + (WidgetTester tester) async { const String title = 'Large SliverAppBar Title'; - const double leadingPadding = 40.0; + const double leadingPadding = 56.0; const double titleSpacing = 16.0; Widget buildWidget({ bool showLeading = true }) { @@ -4382,6 +4423,7 @@ void main() { primary: true, slivers: [ SliverAppBar.large( + automaticallyImplyLeading: false, leading: showLeading ? IconButton( icon: const Icon(Icons.menu), @@ -4402,31 +4444,211 @@ void main() { ); } - await widgetTester.pumpWidget(buildWidget()); + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; // Scroll CustomScrollView to collapse SliverAppBar. - ScrollController controller = primaryScrollController(widgetTester); + ScrollController controller = primaryScrollController(tester); controller.jumpTo(45); - await widgetTester.pumpAndSettle(); + await tester.pumpAndSettle(); // If the leading widget is present, the title widget should be to the // right of the leading widget and title spacing should be respected. - Offset titleOffset = widgetTester.getTopLeft(find.text(title).first); + Offset titleOffset = tester.getTopLeft(collapsedTitle); expect(titleOffset.dx, leadingPadding + titleSpacing); // Hide the leading widget. - await widgetTester.pumpWidget(buildWidget(showLeading: false)); + await tester.pumpWidget(buildWidget(showLeading: false)); // Scroll to collapse the SliverAppBar. - controller = primaryScrollController(widgetTester); + controller = primaryScrollController(tester); controller.jumpTo(45); - await widgetTester.pumpAndSettle(); + await tester.pumpAndSettle(); // If the leading widget is not present, the title widget will // only have the default title spacing. - titleOffset = widgetTester.getTopLeft(find.text(title).first); + titleOffset = tester.getTopLeft(collapsedTitle); expect(titleOffset.dx, titleSpacing); }); + testWidgets( + 'SliverAppBar large & medium title respects automaticallyImplyLeading', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/121511 + const String title = 'AppBar Title'; + const double titleSpacing = 16.0; + + Widget buildWidget() { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: FilledButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute( + builder: (BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + const SliverAppBar.large( + title: Text(title), + ), + SliverToBoxAdapter( + child: Container( + height: 1200, + color: Colors.orange[400], + ), + ), + ], + ), + ); + }, + )); + }, + child: const Text('Go to page'), + ), + ); + } + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(BackButton), findsNothing); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + final Finder collapsedTitle = find.text(title).last; + final Offset backButtonOffset = tester.getTopRight(find.byType(BackButton)); + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + testWidgets('SliverAppBar.medium with bottom widget', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/115091 + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 112; + const double bottomHeight = 48; + const String title = 'Medium App Bar'; + + Widget buildWidget() { + return MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + SliverAppBar.medium( + leading: IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu), + ), + title: const Text(title), + bottom: const TabBar( + tabs: [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + SliverToBoxAdapter( + child: Container( + height: 1200, + color: Colors.orange[400], + ), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight + bottomHeight); + + final Finder expandedTitle = find.text(title).first; + final Offset expandedTitleOffset = tester.getBottomLeft(expandedTitle); + final Offset tabOffset = tester.getTopLeft(find.byType(TabBar)); + expect(expandedTitleOffset.dy, tabOffset.dy); + + // Scroll CustomScrollView to collapse SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + expect(appBarHeight(tester), collapsedAppBarHeight + bottomHeight); + }); + + testWidgets('SliverAppBar.large with bottom widget', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/115091 + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 152; + const double bottomHeight = 48; + const String title = 'Large App Bar'; + + Widget buildWidget() { + return MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + SliverAppBar.large( + leading: IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu), + ), + title: const Text(title), + bottom: const TabBar( + tabs: [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + SliverToBoxAdapter( + child: Container( + height: 1200, + color: Colors.orange[400], + ), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight + bottomHeight); + + final Finder expandedTitle = find.text(title).first; + final Offset expandedTitleOffset = tester.getBottomLeft(expandedTitle); + final Offset tabOffset = tester.getTopLeft(find.byType(TabBar)); + expect(expandedTitleOffset.dy, tabOffset.dy); + + // Scroll CustomScrollView to collapse SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(200); + await tester.pumpAndSettle(); + + expect(appBarHeight(tester), collapsedAppBarHeight + bottomHeight); + }); + group('AppBar.forceMaterialTransparency', () { Material getAppBarMaterial(WidgetTester tester) { return tester.widget(find @@ -4501,4 +4723,177 @@ void main() { expect(buttonWasPressed, isFalse); }); }); + + group('Material 2', () { + // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 + // is turned on by default, these tests can be removed. + + testWidgets('SliverAppBar.medium defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: false); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 112; + + await tester.pumpWidget(MaterialApp( + theme: theme, + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + const SliverAppBar.medium( + title: Text('AppBar Title'), + ), + SliverToBoxAdapter( + child: Container( + height: 1200, + color: Colors.orange[400], + ), + ), + ], + ), + ), + )); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find.ancestor( + of: expandedTitle, + matching: find.byType(ClipRect), + ); + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset, const Offset(16.0, 92.0)); + + // Test the expanded title default color. + expect( + tester.renderObject(expandedTitle).text.style!.color, + theme.colorScheme.onPrimary, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight - 45); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + }); + + testWidgets('SliverAppBar.large defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: false); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 152; + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + const SliverAppBar.large( + title: Text('AppBar Title'), + ), + SliverToBoxAdapter( + child: Container( + height: 1200, + color: Colors.orange[400], + ), + ), + ], + ), + ), + )); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find.ancestor( + of: expandedTitle, + matching: find.byType(ClipRect), + ); + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset, const Offset(16.0, 124.0)); + + // Test the expanded title default color. + expect( + tester.renderObject(expandedTitle).text.style!.color, + theme.colorScheme.onPrimary, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight - 45); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + }); + }); } diff --git a/packages/flutter/test/material/app_bar_theme_test.dart b/packages/flutter/test/material/app_bar_theme_test.dart index 3be9856719d..4866c8c753f 100644 --- a/packages/flutter/test/material/app_bar_theme_test.dart +++ b/packages/flutter/test/material/app_bar_theme_test.dart @@ -9,6 +9,25 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + const AppBarTheme appBarTheme = AppBarTheme( + backgroundColor: Color(0xff00ff00), + foregroundColor: Color(0xff00ffff), + elevation: 4.0, + scrolledUnderElevation: 6.0, + shadowColor: Color(0xff1212ff), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(14.0)), + ), + iconTheme: IconThemeData(color: Color(0xffff0000)), + actionsIconTheme: IconThemeData(color: Color(0xff0000ff)), + centerTitle: false, + titleSpacing: 10.0, + titleTextStyle: TextStyle( + fontSize: 22.0, + fontStyle: FontStyle.italic, + ), + ); + ScrollController primaryScrollController(WidgetTester tester) { return PrimaryScrollController.of(tester.element(find.byType(CustomScrollView))); } @@ -681,18 +700,10 @@ void main() { }); testWidgets('SliverAppBar.medium uses AppBarTheme properties', (WidgetTester tester) async { - const String title = 'Medium SliverAppBar Title'; - const Color foregroundColor = Color(0xff00ff00); - const double titleSpacing = 10.0; + const String title = 'Medium App Bar'; await tester.pumpWidget(MaterialApp( - theme: ThemeData( - appBarTheme: const AppBarTheme( - foregroundColor: foregroundColor, - titleSpacing: titleSpacing, - centerTitle: false, - ), - ), + theme: ThemeData(appBarTheme: appBarTheme), home: CustomScrollView( primary: true, slivers: [ @@ -702,82 +713,130 @@ void main() { icon: const Icon(Icons.menu), ), title: const Text(title), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.search), + ), + ], ), ], ), )); - final RichText text = tester.firstWidget(find.byType(RichText)); - expect(text.text.style!.color, foregroundColor); + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style!.fontSize, appBarTheme.titleTextStyle!.fontSize); + expect(titleText.text.style!.fontStyle, appBarTheme.titleTextStyle!.fontStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(Material).first, + ), + ); + expect(material.color, appBarTheme.backgroundColor); + expect(material.shadowColor, appBarTheme.shadowColor); + expect(material.shape, appBarTheme.shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, appBarTheme.actionsIconTheme!.color); // Scroll to collapse the SliverAppBar. final ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); - final Offset titleOffset = tester.getTopLeft(find.text(title).first); - final Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - // Title spacing should be 10.0. - expect(titleOffset.dx, iconOffset.dx + titleSpacing); + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconOffset.dx + appBarTheme.titleSpacing!); }); testWidgets('SliverAppBar.medium properties take priority over AppBarTheme properties', (WidgetTester tester) async { - const String title = 'Medium SliverAppBar Title'; - const Color foregroundColor = Color(0xff00ff00); - const double titleSpacing = 10.0; + const String title = 'Medium App Bar'; + const Color backgroundColor = Color(0xff000099); + const Color foregroundColor = Color(0xff00ff98); + const Color shadowColor = Color(0xff00ff97); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only(bottomStart: Radius.circular(12.0)), + ); + const IconThemeData iconTheme = IconThemeData(color: Color(0xff00ff96)); + const IconThemeData actionsIconTheme = IconThemeData(color: Color(0xff00ff95)); + const double titleSpacing = 18.0; + const TextStyle titleTextStyle = TextStyle( + fontSize: 22.9, + fontStyle: FontStyle.italic, + ); await tester.pumpWidget(MaterialApp( - theme: ThemeData( - appBarTheme: const AppBarTheme( - foregroundColor: Color(0xffff0000), - titleSpacing: 14.0, - centerTitle: true, - ), - ), + theme: ThemeData(appBarTheme: appBarTheme), home: CustomScrollView( primary: true, slivers: [ SliverAppBar.medium( centerTitle: false, - titleSpacing: titleSpacing, + backgroundColor: backgroundColor, foregroundColor: foregroundColor, + shadowColor: shadowColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + titleSpacing: titleSpacing, + titleTextStyle: titleTextStyle, leading: IconButton( onPressed: () {}, icon: const Icon(Icons.menu), ), title: const Text(title), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.search), + ), + ], ), ], ), )); - final RichText text = tester.firstWidget(find.byType(RichText)); - expect(text.text.style!.color, foregroundColor); + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style, titleTextStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(Material).first, + ), + ); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.shape, shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, actionsIconTheme.color); // Scroll to collapse the SliverAppBar. final ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); - final Offset titleOffset = tester.getTopLeft(find.text(title).first); - final Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - // Title spacing should be 10.0. + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); expect(titleOffset.dx, iconOffset.dx + titleSpacing); }); testWidgets('SliverAppBar.large uses AppBarTheme properties', (WidgetTester tester) async { - const String title = 'Large SliverAppBar Title'; - const Color foregroundColor = Color(0xff00ff00); - const double titleSpacing = 10.0; + const String title = 'Large App Bar'; await tester.pumpWidget(MaterialApp( - theme: ThemeData( - appBarTheme: const AppBarTheme( - foregroundColor: foregroundColor, - titleSpacing: titleSpacing, - centerTitle: false, - ), - ), + theme: ThemeData(appBarTheme: appBarTheme), home: CustomScrollView( primary: true, slivers: [ @@ -787,69 +846,169 @@ void main() { icon: const Icon(Icons.menu), ), title: const Text(title), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.search), + ), + ], ), ], ), )); - final RichText text = tester.firstWidget(find.byType(RichText)); - expect(text.text.style!.color, foregroundColor); + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style!.fontSize, appBarTheme.titleTextStyle!.fontSize); + expect(titleText.text.style!.fontStyle, appBarTheme.titleTextStyle!.fontStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(Material).first, + ), + ); + expect(material.color, appBarTheme.backgroundColor); + expect(material.shadowColor, appBarTheme.shadowColor); + expect(material.shape, appBarTheme.shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, appBarTheme.actionsIconTheme!.color); // Scroll to collapse the SliverAppBar. final ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); - final Offset titleOffset = tester.getTopLeft(find.text(title).first); - final Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - // Title spacing should be 10.0. - expect(titleOffset.dx, iconOffset.dx + titleSpacing); + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); + expect(titleOffset.dx, iconOffset.dx + appBarTheme.titleSpacing!); }); testWidgets('SliverAppBar.large properties take priority over AppBarTheme properties', (WidgetTester tester) async { - const String title = 'Large SliverAppBar Title'; - const Color foregroundColor = Color(0xff00ff00); - const double titleSpacing = 10.0; + const String title = 'Large App Bar'; + const Color backgroundColor = Color(0xff000099); + const Color foregroundColor = Color(0xff00ff98); + const Color shadowColor = Color(0xff00ff97); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only(bottomStart: Radius.circular(12.0)), + ); + const IconThemeData iconTheme = IconThemeData(color: Color(0xff00ff96)); + const IconThemeData actionsIconTheme = IconThemeData(color: Color(0xff00ff95)); + const double titleSpacing = 18.0; + const TextStyle titleTextStyle = TextStyle( + fontSize: 22.9, + fontStyle: FontStyle.italic, + ); await tester.pumpWidget(MaterialApp( - theme: ThemeData( - appBarTheme: const AppBarTheme( - foregroundColor: Color(0xffff0000), - titleSpacing: 14.0, - centerTitle: true, - ), - ), + theme: ThemeData(appBarTheme: appBarTheme), home: CustomScrollView( primary: true, slivers: [ SliverAppBar.large( centerTitle: false, - titleSpacing: titleSpacing, + backgroundColor: backgroundColor, foregroundColor: foregroundColor, + shadowColor: shadowColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + titleSpacing: titleSpacing, + titleTextStyle: titleTextStyle, leading: IconButton( onPressed: () {}, icon: const Icon(Icons.menu), ), title: const Text(title), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.search), + ), + ], ), ], ), )); - final RichText text = tester.firstWidget(find.byType(RichText)); - expect(text.text.style!.color, foregroundColor); + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style, titleTextStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(Material).first, + ), + ); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.shape, shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, actionsIconTheme.color); // Scroll to collapse the SliverAppBar. final ScrollController controller = primaryScrollController(tester); - controller.jumpTo(45); + controller.jumpTo(120); await tester.pumpAndSettle(); - final Offset titleOffset = tester.getTopLeft(find.text(title).first); - final Offset iconOffset = tester.getTopRight(find.byIcon(Icons.menu)); - // Title spacing should be 10.0. + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight(find.widgetWithIcon(IconButton, Icons.menu)); expect(titleOffset.dx, iconOffset.dx + titleSpacing); }); + testWidgets( + 'SliverAppBar medium & large supports foregroundColor', (WidgetTester tester) async { + const String title = 'AppBar title'; + const AppBarTheme appBarTheme = AppBarTheme(foregroundColor: Color(0xff00ff20)); + const Color foregroundColor = Color(0xff001298); + + Widget buildWidget({ Color? color, AppBarTheme? appBarTheme }) { + return MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: [ + SliverAppBar.medium( + foregroundColor: color, + title: const Text(title), + ), + SliverAppBar.large( + foregroundColor: color, + title: const Text(title), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildWidget(appBarTheme: appBarTheme)); + + // Test AppBarTheme.foregroundColor parameter. + RichText mediumTitle = tester.widget(find.byType(RichText).first); + expect(mediumTitle.text.style!.color, appBarTheme.foregroundColor); + RichText largeTitle = tester.widget(find.byType(RichText).first); + expect(largeTitle.text.style!.color, appBarTheme.foregroundColor); + + await tester.pumpWidget(buildWidget( + color: foregroundColor, appBarTheme: appBarTheme), + ); + + // Test foregroundColor parameter. + mediumTitle = tester.widget(find.byType(RichText).first); + expect(mediumTitle.text.style!.color, foregroundColor); + largeTitle = tester.widget(find.byType(RichText).first); + expect(largeTitle.text.style!.color, foregroundColor); + }); + testWidgets('Default AppBarTheme debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); const AppBarTheme().debugFillProperties(builder); diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index 6c74a6513c5..c062cba1fd6 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -2720,8 +2720,8 @@ void main() { return [ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: SliverAppBar.medium( - title: const Text('AppBar Title'), + sliver: const SliverAppBar.medium( + title: Text('AppBar Title'), ), ), ]; @@ -2747,11 +2747,11 @@ void main() { )); // There are two widgets for the title. - final Finder expandedTitle = find.text('AppBar Title').last; + final Finder expandedTitle = find.text('AppBar Title').first; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), - ); + ).first; // Default, fully expanded app bar. expect(nestedScrollView.currentState?.outerController.offset, 0); @@ -2830,11 +2830,11 @@ void main() { )); // There are two widgets for the title. - final Finder expandedTitle = find.text('AppBar Title').last; + final Finder expandedTitle = find.text('AppBar Title').first; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), - ); + ).first; // Default, fully expanded app bar. expect(nestedScrollView.currentState?.outerController.offset, 0);