blob: bae405aff0acf874595dbba7dfd747de05d2342b [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.
/// @docImport 'pinned_header_sliver.dart';
/// @docImport 'scroll_view.dart';
/// @docImport 'sliver_persistent_header.dart';
/// @docImport 'sliver_resizing_header.dart';
library;
import 'dart:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'framework.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
import 'ticker_provider.dart';
/// Specifies how a partially visible [SliverFloatingHeader] animates
/// into a view when a user scroll gesture ends.
///
/// During a user scroll gesture the header and the rest of the scrollable
/// content move in sync. If the header is partially visible when the
/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
/// content as it expands until it's completely visible, or if the
/// content should scroll out of the way as the header expands.
enum FloatingHeaderSnapMode {
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
/// expand over the scrollable's content.
overlay,
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
/// expand and the scrollable's content will continue to scroll out
/// of the way.
scroll,
}
/// A sliver that shows its [child] when the user scrolls forward and hides it
/// when the user scrolls backwards.
///
/// This sliver is preferable to the general purpose [SliverPersistentHeader]
/// for its relatively narrow use case because there's no need to create a
/// [SliverPersistentHeaderDelegate] or to predict the header's size.
///
/// {@tool dartpad}
/// This example shows how to create a SliverFloatingHeader.
///
/// ** See code in examples/api/lib/widgets/sliver/sliver_floating_header.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [PinnedHeaderSliver] - which just pins the header at the top
/// of the [CustomScrollView].
/// * [SliverResizingHeader] - which similarly pins the header at the top
/// of the [CustomScrollView] but reacts to scrolling by resizing the header
/// between its minimum and maximum extent limits.
/// * [SliverPersistentHeader] - a general purpose header that can be
/// configured as a pinned, resizing, or floating header.
class SliverFloatingHeader extends StatefulWidget {
/// Create a floating header sliver that animates into view when the user
/// scrolls forward, and disappears the user starts scrolling in the
/// opposite direction.
const SliverFloatingHeader({
super.key,
this.animationStyle,
this.snapMode,
required this.child
});
/// Non null properties override the default durations (300ms) and
/// curves (Curves.easeInOut) for subsequent header animations.
///
/// The reverse duration and curve apply to the animation that hides the header.
final AnimationStyle? animationStyle;
/// Specifies how a partially visible [SliverFloatingHeader] animates
/// into a view when a user scroll gesture ends.
///
/// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
/// modify an animation in progress, just subsequent animations.
final FloatingHeaderSnapMode? snapMode;
/// The widget contained by this sliver.
final Widget child;
@override
State<SliverFloatingHeader> createState() => _SliverFloatingHeaderState();
}
class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with SingleTickerProviderStateMixin {
ScrollPosition? position;
@override
Widget build(BuildContext context) {
return _SliverFloatingHeader(
vsync: this,
animationStyle: widget.animationStyle,
snapMode: widget.snapMode,
child: _SnapTrigger(widget.child),
);
}
}
class _SnapTrigger extends StatefulWidget {
const _SnapTrigger(this.child);
final Widget child;
@override
_SnapTriggerState createState() => _SnapTriggerState();
}
class _SnapTriggerState extends State<_SnapTrigger> {
ScrollPosition? position;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (position != null) {
position!.isScrollingNotifier.removeListener(isScrollingListener);
}
position = Scrollable.maybeOf(context)?.position;
if (position != null) {
position!.isScrollingNotifier.addListener(isScrollingListener);
}
}
@override
void dispose() {
if (position != null) {
position!.isScrollingNotifier.removeListener(isScrollingListener);
}
super.dispose();
}
// Called when the sliver starts or ends scrolling.
void isScrollingListener() {
assert(position != null);
final _RenderSliverFloatingHeader? renderer = context.findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>();
renderer?.isScrollingUpdate(position!);
}
@override
Widget build(BuildContext context) => widget.child;
}
class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
const _SliverFloatingHeader({
this.vsync,
this.animationStyle,
this.snapMode,
super.child,
});
final TickerProvider? vsync;
final AnimationStyle? animationStyle;
final FloatingHeaderSnapMode? snapMode;
@override
_RenderSliverFloatingHeader createRenderObject(BuildContext context) {
return _RenderSliverFloatingHeader(
vsync: vsync,
animationStyle: animationStyle,
snapMode: snapMode,
);
}
@override
void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
renderObject
..vsync = vsync
..animationStyle = animationStyle
..snapMode = snapMode;
}
}
class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
_RenderSliverFloatingHeader({
TickerProvider? vsync,
this.animationStyle,
this.snapMode,
}) : _vsync = vsync;
late Animation<double> snapAnimation;
AnimationController? snapController;
double? lastScrollOffset;
// The distance from the start of the header to the start of the viewport. When the
// header is showing it varies between 0 (completely visible) and childExtent (not visible
// because it's just above the viewport's starting edge). It's used to compute the
// header's paintExtent which defines where the header will appear - see paint().
late double effectiveScrollOffset;
TickerProvider? get vsync => _vsync;
TickerProvider? _vsync;
set vsync(TickerProvider? value) {
if (value == _vsync) {
return;
}
_vsync = value;
if (value == null) {
snapController?.dispose();
snapController = null;
} else {
snapController?.resync(value);
}
}
AnimationStyle? animationStyle;
FloatingHeaderSnapMode? snapMode;
// Called each time the position's isScrollingNotifier indicates that user scrolling has
// stopped or started, i.e. if the sliver "is scrolling".
void isScrollingUpdate(ScrollPosition position) {
if (position.isScrollingNotifier.value) {
snapController?.stop();
} else {
final ScrollDirection direction = position.userScrollDirection;
final bool headerIsPartiallyVisible = switch (direction) {
ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible
ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible
_ => true,
};
if (headerIsPartiallyVisible) {
snapController ??= AnimationController(vsync: vsync!)
..addListener(() {
if (effectiveScrollOffset != snapAnimation.value) {
effectiveScrollOffset = snapAnimation.value;
markNeedsLayout();
}
});
snapController!.duration = switch (direction) {
ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300),
_ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300),
};
snapAnimation = snapController!.drive(
Tween<double>(
begin: effectiveScrollOffset,
end: switch (direction) {
ScrollDirection.forward => 0,
_ => childExtent,
},
).chain(
CurveTween(
curve: switch (direction) {
ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut,
_ => animationStyle?.reverseCurve ?? Curves.easeInOut,
}
),
),
);
snapController!.forward(from: 0.0);
}
}
}
double get childExtent {
if (child == null) {
return 0.0;
}
assert(child!.hasSize);
return switch (constraints.axis) {
Axis.vertical => child!.size.height,
Axis.horizontal => child!.size.width,
};
}
@override
void detach() {
snapController?.dispose();
snapController = null; // lazily recreated if we're reattached.
super.detach();
}
// True if the header has been laid at at least once (lastScrollOffset != null) and either:
// - We're scrolling forward: constraints.scrollOffset < lastScrollOffset
// - The header's already partially visible: effectiveScrollOffset < childExtent
// Scrolling forwards (towards the scrollable's start) is the trigger that causes the
// header to be shown.
bool get floatingHeaderNeedsToBeUpdated {
return lastScrollOffset != null &&
(constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent);
}
@override
void performLayout() {
if (!floatingHeaderNeedsToBeUpdated) {
effectiveScrollOffset = constraints.scrollOffset;
} else {
double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing
if (constraints.userScrollDirection == ScrollDirection.forward) {
if (effectiveScrollOffset > childExtent) {
effectiveScrollOffset = childExtent; // The header is now just above the start edge of viewport.
}
} else {
// delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
delta = clampDouble(delta, -double.infinity, 0);
}
effectiveScrollOffset = clampDouble(effectiveScrollOffset - delta, 0.0, constraints.scrollOffset);
}
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double paintExtent = childExtent - effectiveScrollOffset;
final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
FloatingHeaderSnapMode.scroll => paintExtent,
};
geometry = SliverGeometry(
paintOrigin: math.min(constraints.overlap, 0.0),
scrollExtent: childExtent,
paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),
layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent),
maxPaintExtent: childExtent,
hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
);
lastScrollOffset = constraints.scrollOffset;
}
@override
double childMainAxisPosition(covariant RenderObject child) {
return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent);
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
applyPaintTransformForBoxChild(child as RenderBox, transform);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
offset += switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
AxisDirection.up => Offset(0.0, geometry!.paintExtent - childMainAxisPosition(child!) - childExtent),
AxisDirection.left => Offset(geometry!.paintExtent - childMainAxisPosition(child!) - childExtent, 0.0),
AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0),
AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)),
};
context.paintChild(child!, offset);
}
}
}