blob: 3b3fd352a769229e6180815e45214d1cf5e34529 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'theme.dart';
/// The collapsing effect while the space bar collapses from its full size.
enum CollapseMode {
/// The background widget will scroll in a parallax fashion.
parallax,
/// The background widget pin in place until it reaches the min extent.
pin,
/// The background widget will act as normal with no collapsing effect.
none,
}
/// The stretching effect while the space bar stretches beyond its full size.
enum StretchMode {
/// The background widget will expand to fill the extra space.
zoomBackground,
/// The background will blur using a [ImageFilter.blur] effect.
blurBackground,
/// The title will fade away as the user over-scrolls.
fadeTitle,
}
/// The part of a Material Design [AppBar] that expands, collapses, and
/// stretches.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=mSc7qFzxHDw}
///
/// Most commonly used in the [SliverAppBar.flexibleSpace] field, a flexible
/// space bar expands and contracts as the app scrolls so that the [AppBar]
/// reaches from the top of the app to the top of the scrolling contents of the
/// app. When using [SliverAppBar.flexibleSpace], the [SliverAppBar.expandedHeight]
/// must be large enough to accommodate the [SliverAppBar.flexibleSpace] widget.
///
/// Furthermore is included functionality for stretch behavior. When
/// [SliverAppBar.stretch] is true, and your [ScrollPhysics] allow for
/// overscroll, this space will stretch with the overscroll.
///
/// The widget that sizes the [AppBar] must wrap it in the widget returned by
/// [FlexibleSpaceBar.createSettings], to convey sizing information down to the
/// [FlexibleSpaceBar].
///
/// {@tool dartpad}
/// This sample application demonstrates the different features of the
/// [FlexibleSpaceBar] when used in a [SliverAppBar]. This app bar is configured
/// to stretch into the overscroll space, and uses the
/// [FlexibleSpaceBar.stretchModes] to apply `fadeTitle`, `blurBackground` and
/// `zoomBackground`. The app bar also makes use of [CollapseMode.parallax] by
/// default.
///
/// ** See code in examples/api/lib/material/flexible_space_bar/flexible_space_bar.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [SliverAppBar], which implements the expanding and contracting.
/// * [AppBar], which is used by [SliverAppBar].
/// * <https://material.io/design/components/app-bars-top.html#behavior>
class FlexibleSpaceBar extends StatefulWidget {
/// Creates a flexible space bar.
///
/// Most commonly used in the [AppBar.flexibleSpace] field.
const FlexibleSpaceBar({
super.key,
this.title,
this.background,
this.centerTitle,
this.titlePadding,
this.collapseMode = CollapseMode.parallax,
this.stretchModes = const <StretchMode>[StretchMode.zoomBackground],
this.expandedTitleScale = 1.5,
}) : assert(expandedTitleScale >= 1);
/// The primary contents of the flexible space bar when expanded.
///
/// Typically a [Text] widget.
final Widget? title;
/// Shown behind the [title] when expanded.
///
/// Typically an [Image] widget with [Image.fit] set to [BoxFit.cover].
final Widget? background;
/// Whether the title should be centered.
///
/// By default this property is true if the current target platform
/// is [TargetPlatform.iOS] or [TargetPlatform.macOS], false otherwise.
final bool? centerTitle;
/// Collapse effect while scrolling.
///
/// Defaults to [CollapseMode.parallax].
final CollapseMode collapseMode;
/// Stretch effect while over-scrolling.
///
/// Defaults to include [StretchMode.zoomBackground].
final List<StretchMode> stretchModes;
/// Defines how far the [title] is inset from either the widget's
/// bottom-left or its center.
///
/// Typically this property is used to adjust how far the title is
/// inset from the bottom-left and it is specified along with
/// [centerTitle] false.
///
/// By default the value of this property is
/// `EdgeInsetsDirectional.only(start: 72, bottom: 16)` if the title is
/// not centered, `EdgeInsetsDirectional.only(start: 0, bottom: 16)` otherwise.
final EdgeInsetsGeometry? titlePadding;
/// Defines how much the title is scaled when the FlexibleSpaceBar is expanded
/// due to the user scrolling downwards. The title is scaled uniformly on the
/// x and y axes while maintaining its bottom-left position (bottom-center if
/// [centerTitle] is true).
///
/// Defaults to 1.5 and must be greater than 1.
final double expandedTitleScale;
/// Wraps a widget that contains an [AppBar] to convey sizing information down
/// to the [FlexibleSpaceBar].
///
/// Used by [Scaffold] and [SliverAppBar].
///
/// `toolbarOpacity` affects how transparent the text within the toolbar
/// appears. `minExtent` sets the minimum height of the resulting
/// [FlexibleSpaceBar] when fully collapsed. `maxExtent` sets the maximum
/// height of the resulting [FlexibleSpaceBar] when fully expanded.
/// `currentExtent` sets the scale of the [FlexibleSpaceBar.background] and
/// [FlexibleSpaceBar.title] widgets of [FlexibleSpaceBar] upon
/// initialization. `scrolledUnder` is true if the [FlexibleSpaceBar]
/// overlaps the app's primary scrollable, false if it does not, and null
/// if the caller has not determined as much.
/// See also:
///
/// * [FlexibleSpaceBarSettings] which creates a settings object that can be
/// used to specify these settings to a [FlexibleSpaceBar].
static Widget createSettings({
double? toolbarOpacity,
double? minExtent,
double? maxExtent,
bool? isScrolledUnder,
required double currentExtent,
required Widget child,
}) {
return FlexibleSpaceBarSettings(
toolbarOpacity: toolbarOpacity ?? 1.0,
minExtent: minExtent ?? currentExtent,
maxExtent: maxExtent ?? currentExtent,
isScrolledUnder: isScrolledUnder,
currentExtent: currentExtent,
child: child,
);
}
@override
State<FlexibleSpaceBar> createState() => _FlexibleSpaceBarState();
}
class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
bool _getEffectiveCenterTitle(ThemeData theme) {
if (widget.centerTitle != null) {
return widget.centerTitle!;
}
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;
}
}
Alignment _getTitleAlignment(bool effectiveCenterTitle) {
if (effectiveCenterTitle) {
return Alignment.bottomCenter;
}
final TextDirection textDirection = Directionality.of(context);
switch (textDirection) {
case TextDirection.rtl:
return Alignment.bottomRight;
case TextDirection.ltr:
return Alignment.bottomLeft;
}
}
double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) {
switch (widget.collapseMode) {
case CollapseMode.pin:
return -(settings.maxExtent - settings.currentExtent);
case CollapseMode.none:
return 0.0;
case CollapseMode.parallax:
final double deltaExtent = settings.maxExtent - settings.minExtent;
return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final FlexibleSpaceBarSettings settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
final List<Widget> children = <Widget>[];
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t = clampDouble(1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent, 0.0, 1.0);
// background
if (widget.background != null) {
final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
const double fadeEnd = 1.0;
assert(fadeStart <= fadeEnd);
// If the min and max extent are the same, the app bar cannot collapse
// and the content should be visible, so opacity = 1.
final double opacity = settings.maxExtent == settings.minExtent
? 1.0
: 1.0 - Interval(fadeStart, fadeEnd).transform(t);
double height = settings.maxExtent;
// StretchMode.zoomBackground
if (widget.stretchModes.contains(StretchMode.zoomBackground) &&
constraints.maxHeight > height) {
height = constraints.maxHeight;
}
final double topPadding = _getCollapsePadding(t, settings);
children.add(Positioned(
top: topPadding,
left: 0.0,
right: 0.0,
height: height,
child: _FlexibleSpaceHeaderOpacity(
// IOS is relying on this semantics node to correctly traverse
// through the app bar when it is collapsed.
alwaysIncludeSemantics: true,
opacity: opacity,
child: widget.background
),
));
// StretchMode.blurBackground
if (widget.stretchModes.contains(StretchMode.blurBackground) &&
constraints.maxHeight > settings.maxExtent) {
final double blurAmount = (constraints.maxHeight - settings.maxExtent) / 10;
children.add(Positioned.fill(
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: blurAmount,
sigmaY: blurAmount,
),
child: Container(
color: Colors.transparent,
),
),
));
}
}
// title
if (widget.title != null) {
final ThemeData theme = Theme.of(context);
Widget? title;
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
title = widget.title;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
title = Semantics(
namesRoute: true,
child: widget.title,
);
}
// StretchMode.fadeTitle
if (widget.stretchModes.contains(StretchMode.fadeTitle) &&
constraints.maxHeight > settings.maxExtent) {
final double stretchOpacity = 1 -
clampDouble(
(constraints.maxHeight - settings.maxExtent) / 100,
0.0,
1.0);
title = Opacity(
opacity: stretchOpacity,
child: title,
);
}
final double opacity = settings.toolbarOpacity;
if (opacity > 0.0) {
TextStyle titleStyle = theme.primaryTextTheme.titleLarge!;
titleStyle = titleStyle.copyWith(
color: titleStyle.color!.withOpacity(opacity),
);
final bool effectiveCenterTitle = _getEffectiveCenterTitle(theme);
final EdgeInsetsGeometry padding = widget.titlePadding ??
EdgeInsetsDirectional.only(
start: effectiveCenterTitle ? 0.0 : 72.0,
bottom: 16.0,
);
final double scaleValue = Tween<double>(begin: widget.expandedTitleScale, end: 1.0).transform(t);
final Matrix4 scaleTransform = Matrix4.identity()
..scale(scaleValue, scaleValue, 1.0);
final Alignment titleAlignment = _getTitleAlignment(effectiveCenterTitle);
children.add(Container(
padding: padding,
child: Transform(
alignment: titleAlignment,
transform: scaleTransform,
child: Align(
alignment: titleAlignment,
child: DefaultTextStyle(
style: titleStyle,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Container(
width: constraints.maxWidth / scaleValue,
alignment: titleAlignment,
child: title,
);
},
),
),
),
),
));
}
}
return ClipRect(child: Stack(children: children));
},
);
}
}
/// Provides sizing and opacity information to a [FlexibleSpaceBar].
///
/// See also:
///
/// * [FlexibleSpaceBar] which creates a flexible space bar.
class FlexibleSpaceBarSettings extends InheritedWidget {
/// Creates a Flexible Space Bar Settings widget.
///
/// Used by [Scaffold] and [SliverAppBar]. [child] must have a
/// [FlexibleSpaceBar] widget in its tree for the settings to take affect.
///
/// The required [toolbarOpacity], [minExtent], [maxExtent], [currentExtent],
/// and [child] parameters must not be null.
const FlexibleSpaceBarSettings({
super.key,
required this.toolbarOpacity,
required this.minExtent,
required this.maxExtent,
required this.currentExtent,
required super.child,
this.isScrolledUnder,
}) : assert(minExtent >= 0),
assert(maxExtent >= 0),
assert(currentExtent >= 0),
assert(toolbarOpacity >= 0.0),
assert(minExtent <= maxExtent),
assert(minExtent <= currentExtent),
assert(currentExtent <= maxExtent);
/// Affects how transparent the text within the toolbar appears.
final double toolbarOpacity;
/// Minimum height of the resulting [FlexibleSpaceBar] when fully collapsed.
final double minExtent;
/// Maximum height of the resulting [FlexibleSpaceBar] when fully expanded.
final double maxExtent;
/// If the [FlexibleSpaceBar.title] or the [FlexibleSpaceBar.background] is
/// not null, then this value is used to calculate the relative scale of
/// these elements upon initialization.
final double currentExtent;
/// True if the FlexibleSpaceBar overlaps the primary scrollable's contents.
///
/// This value is used by the [AppBar] to resolve
/// [AppBar.backgroundColor] against [MaterialState.scrolledUnder],
/// i.e. to enable apps to specify different colors when content
/// has been scrolled up and behind the app bar.
///
/// Null if the caller hasn't determined if the FlexibleSpaceBar
/// overlaps the primary scrollable's contents.
final bool? isScrolledUnder;
@override
bool updateShouldNotify(FlexibleSpaceBarSettings oldWidget) {
return toolbarOpacity != oldWidget.toolbarOpacity
|| minExtent != oldWidget.minExtent
|| maxExtent != oldWidget.maxExtent
|| currentExtent != oldWidget.currentExtent
|| isScrolledUnder != oldWidget.isScrolledUnder;
}
}
// We need the child widget to repaint, however both the opacity
// and potentially `widget.background` can be constant which won't
// lead to repainting.
// see: https://github.com/flutter/flutter/issues/127836
class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget {
const _FlexibleSpaceHeaderOpacity({required this.opacity, required super.child, required this.alwaysIncludeSemantics});
final double opacity;
final bool alwaysIncludeSemantics;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderFlexibleSpaceHeaderOpacity(opacity: opacity, alwaysIncludeSemantics: alwaysIncludeSemantics);
}
@override
void updateRenderObject(BuildContext context, covariant _RenderFlexibleSpaceHeaderOpacity renderObject) {
renderObject
..alwaysIncludeSemantics = alwaysIncludeSemantics
..opacity = opacity;
}
}
class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity {
_RenderFlexibleSpaceHeaderOpacity({super.opacity, super.alwaysIncludeSemantics});
@override
bool get isRepaintBoundary => false;
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) {
return;
}
if (opacity == 0) {
layer = null;
return;
}
assert(needsCompositing);
layer = context.pushOpacity(offset, (opacity * 255).round(), super.paint, oldLayer: layer as OpacityLayer?);
assert(() {
layer!.debugCreator = debugCreator;
return true;
}());
}
}