blob: 5d541f1d38f5133ed4ba4b8ff357efb9c0e06476 [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';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'activity_indicator.dart';
const double _kActivityIndicatorRadius = 14.0;
const double _kActivityIndicatorMargin = 16.0;
class _CupertinoSliverRefresh extends SingleChildRenderObjectWidget {
const _CupertinoSliverRefresh({
Key? key,
this.refreshIndicatorLayoutExtent = 0.0,
this.hasLayoutExtent = false,
Widget? child,
}) : assert(refreshIndicatorLayoutExtent != null),
assert(refreshIndicatorLayoutExtent >= 0.0),
assert(hasLayoutExtent != null),
super(key: key, child: child);
// The amount of space the indicator should occupy in the sliver in a
// resting state when in the refreshing mode.
final double refreshIndicatorLayoutExtent;
// _RenderCupertinoSliverRefresh will paint the child in the available
// space either way but this instructs the _RenderCupertinoSliverRefresh
// on whether to also occupy any layoutExtent space or not.
final bool hasLayoutExtent;
@override
_RenderCupertinoSliverRefresh createRenderObject(BuildContext context) {
return _RenderCupertinoSliverRefresh(
refreshIndicatorExtent: refreshIndicatorLayoutExtent,
hasLayoutExtent: hasLayoutExtent,
);
}
@override
void updateRenderObject(BuildContext context, covariant _RenderCupertinoSliverRefresh renderObject) {
renderObject
..refreshIndicatorLayoutExtent = refreshIndicatorLayoutExtent
..hasLayoutExtent = hasLayoutExtent;
}
}
// RenderSliver object that gives its child RenderBox object space to paint
// in the overscrolled gap and may or may not hold that overscrolled gap
// around the RenderBox depending on whether [layoutExtent] is set.
//
// The [layoutExtentOffsetCompensation] field keeps internal accounting to
// prevent scroll position jumps as the [layoutExtent] is set and unset.
class _RenderCupertinoSliverRefresh extends RenderSliver
with RenderObjectWithChildMixin<RenderBox> {
_RenderCupertinoSliverRefresh({
required double refreshIndicatorExtent,
required bool hasLayoutExtent,
RenderBox? child,
}) : assert(refreshIndicatorExtent != null),
assert(refreshIndicatorExtent >= 0.0),
assert(hasLayoutExtent != null),
_refreshIndicatorExtent = refreshIndicatorExtent,
_hasLayoutExtent = hasLayoutExtent {
this.child = child;
}
// The amount of layout space the indicator should occupy in the sliver in a
// resting state when in the refreshing mode.
double get refreshIndicatorLayoutExtent => _refreshIndicatorExtent;
double _refreshIndicatorExtent;
set refreshIndicatorLayoutExtent(double value) {
assert(value != null);
assert(value >= 0.0);
if (value == _refreshIndicatorExtent)
return;
_refreshIndicatorExtent = value;
markNeedsLayout();
}
// The child box will be laid out and painted in the available space either
// way but this determines whether to also occupy any
// [SliverGeometry.layoutExtent] space or not.
bool get hasLayoutExtent => _hasLayoutExtent;
bool _hasLayoutExtent;
set hasLayoutExtent(bool value) {
assert(value != null);
if (value == _hasLayoutExtent)
return;
_hasLayoutExtent = value;
markNeedsLayout();
}
// This keeps track of the previously applied scroll offsets to the scrollable
// so that when [refreshIndicatorLayoutExtent] or [hasLayoutExtent] changes,
// the appropriate delta can be applied to keep everything in the same place
// visually.
double layoutExtentOffsetCompensation = 0.0;
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
// Only pulling to refresh from the top is currently supported.
assert(constraints.axisDirection == AxisDirection.down);
assert(constraints.growthDirection == GrowthDirection.forward);
// The new layout extent this sliver should now have.
final double layoutExtent =
(_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent;
// If the new layoutExtent instructive changed, the SliverGeometry's
// layoutExtent will take that value (on the next performLayout run). Shift
// the scroll offset first so it doesn't make the scroll position suddenly jump.
if (layoutExtent != layoutExtentOffsetCompensation) {
geometry = SliverGeometry(
scrollOffsetCorrection: layoutExtent - layoutExtentOffsetCompensation,
);
layoutExtentOffsetCompensation = layoutExtent;
// Return so we don't have to do temporary accounting and adjusting the
// child's constraints accounting for this one transient frame using a
// combination of existing layout extent, new layout extent change and
// the overlap.
return;
}
final bool active = constraints.overlap < 0.0 || layoutExtent > 0.0;
final double overscrolledExtent =
constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
// Layout the child giving it the space of the currently dragged overscroll
// which may or may not include a sliver layout extent space that it will
// keep after the user lets go during the refresh process.
child!.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent
// Plus only the overscrolled portion immediately preceding this
// sliver.
+ overscrolledExtent,
),
parentUsesSize: true,
);
if (active) {
geometry = SliverGeometry(
scrollExtent: layoutExtent,
paintOrigin: -overscrolledExtent - constraints.scrollOffset,
paintExtent: max(
// Check child size (which can come from overscroll) because
// layoutExtent may be zero. Check layoutExtent also since even
// with a layoutExtent, the indicator builder may decide to not
// build anything.
max(child!.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
maxPaintExtent: max(
max(child!.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
layoutExtent: max(layoutExtent - constraints.scrollOffset, 0.0),
);
} else {
// If we never started overscrolling, return no geometry.
geometry = SliverGeometry.zero;
}
}
@override
void paint(PaintingContext paintContext, Offset offset) {
if (constraints.overlap < 0.0 ||
constraints.scrollOffset + child!.size.height > 0) {
paintContext.paintChild(child!, offset);
}
}
// Nothing special done here because this sliver always paints its child
// exactly between paintOrigin and paintExtent.
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) { }
}
/// The current state of the refresh control.
///
/// Passed into the [RefreshControlIndicatorBuilder] builder function so
/// users can show different UI in different modes.
enum RefreshIndicatorMode {
/// Initial state, when not being overscrolled into, or after the overscroll
/// is canceled or after done and the sliver retracted away.
inactive,
/// While being overscrolled but not far enough yet to trigger the refresh.
drag,
/// Dragged far enough that the onRefresh callback will run and the dragged
/// displacement is not yet at the final refresh resting state.
armed,
/// While the onRefresh task is running.
refresh,
/// While the indicator is animating away after refreshing.
done,
}
/// Signature for a builder that can create a different widget to show in the
/// refresh indicator space depending on the current state of the refresh
/// control and the space available.
///
/// The `refreshTriggerPullDistance` and `refreshIndicatorExtent` parameters are
/// the same values passed into the [CupertinoSliverRefreshControl].
///
/// The `pulledExtent` parameter is the currently available space either from
/// overscrolling or as held by the sliver during refresh.
typedef RefreshControlIndicatorBuilder = Widget Function(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
);
/// A callback function that's invoked when the [CupertinoSliverRefreshControl] is
/// pulled a `refreshTriggerPullDistance`. Must return a [Future]. Upon
/// completion of the [Future], the [CupertinoSliverRefreshControl] enters the
/// [RefreshIndicatorMode.done] state and will start to go away.
typedef RefreshCallback = Future<void> Function();
/// A sliver widget implementing the iOS-style pull to refresh content control.
///
/// When inserted as the first sliver in a scroll view or behind other slivers
/// that still lets the scrollable overscroll in front of this sliver (such as
/// the [CupertinoSliverNavigationBar], this widget will:
///
/// * Let the user draw inside the overscrolled area via the passed in [builder].
/// * Trigger the provided [onRefresh] function when overscrolled far enough to
/// pass [refreshTriggerPullDistance].
/// * Continue to hold [refreshIndicatorExtent] amount of space for the [builder]
/// to keep drawing inside of as the [Future] returned by [onRefresh] processes.
/// * Scroll away once the [onRefresh] [Future] completes.
///
/// The [builder] function will be informed of the current [RefreshIndicatorMode]
/// when invoking it, except in the [RefreshIndicatorMode.inactive] state when
/// no space is available and nothing needs to be built. The [builder] function
/// will otherwise be continuously invoked as the amount of space available
/// changes from overscroll, as the sliver scrolls away after the [onRefresh]
/// task is done, etc.
///
/// Only one refresh can be triggered until the previous refresh has completed
/// and the indicator sliver has retracted at least 90% of the way back.
///
/// Can only be used in downward-scrolling vertical lists that overscrolls. In
/// other words, refreshes can't be triggered with [Scrollable]s using
/// [ClampingScrollPhysics] which is the default on Android. To allow overscroll
/// on Android, use an overscrolling physics such as [BouncingScrollPhysics].
/// This can be done via:
///
/// * Providing a [BouncingScrollPhysics] (possibly in combination with a
/// [AlwaysScrollableScrollPhysics]) while constructing the scrollable.
/// * By inserting a [ScrollConfiguration] with [BouncingScrollPhysics] above
/// the scrollable.
/// * By using [CupertinoApp], which always uses a [ScrollConfiguration]
/// with [BouncingScrollPhysics] regardless of platform.
///
/// In a typical application, this sliver should be inserted between the app bar
/// sliver such as [CupertinoSliverNavigationBar] and your main scrollable
/// content's sliver.
///
/// {@tool dartpad}
/// When the user scrolls past [refreshTriggerPullDistance],
/// this sample shows the default iOS pull to refresh indicator for 1 second and
/// adds a new item to the top of the list view.
///
/// ** See code in examples/api/lib/cupertino/refresh/cupertino_sliver_refresh_control.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CustomScrollView], a typical sliver holding scroll view this control
/// should go into.
/// * <https://developer.apple.com/ios/human-interface-guidelines/controls/refresh-content-controls/>
/// * [RefreshIndicator], a Material Design version of the pull-to-refresh
/// paradigm. This widget works differently than [RefreshIndicator] because
/// instead of being an overlay on top of the scrollable, the
/// [CupertinoSliverRefreshControl] is part of the scrollable and actively occupies
/// scrollable space.
class CupertinoSliverRefreshControl extends StatefulWidget {
/// Create a new refresh control for inserting into a list of slivers.
///
/// The [refreshTriggerPullDistance] and [refreshIndicatorExtent] arguments
/// must not be null and must be >= 0.
///
/// The [builder] argument may be null, in which case no indicator UI will be
/// shown but the [onRefresh] will still be invoked. By default, [builder]
/// shows a [CupertinoActivityIndicator].
///
/// The [onRefresh] argument will be called when pulled far enough to trigger
/// a refresh.
const CupertinoSliverRefreshControl({
Key? key,
this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance,
this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent,
this.builder = buildRefreshIndicator,
this.onRefresh,
}) : assert(refreshTriggerPullDistance != null),
assert(refreshTriggerPullDistance > 0.0),
assert(refreshIndicatorExtent != null),
assert(refreshIndicatorExtent >= 0.0),
assert(
refreshTriggerPullDistance >= refreshIndicatorExtent,
'The refresh indicator cannot take more space in its final state '
'than the amount initially created by overscrolling.',
),
super(key: key);
/// The amount of overscroll the scrollable must be dragged to trigger a reload.
///
/// Must not be null, must be larger than 0.0 and larger than
/// [refreshIndicatorExtent]. Defaults to 100px when not specified.
///
/// When overscrolled past this distance, [onRefresh] will be called if not
/// null and the [builder] will build in the [RefreshIndicatorMode.armed] state.
final double refreshTriggerPullDistance;
/// The amount of space the refresh indicator sliver will keep holding while
/// [onRefresh]'s [Future] is still running.
///
/// Must not be null and must be positive, but can be 0.0, in which case the
/// sliver will start retracting back to 0.0 as soon as the refresh is started.
/// Defaults to 60px when not specified.
///
/// Must be smaller than [refreshTriggerPullDistance], since the sliver
/// shouldn't grow further after triggering the refresh.
final double refreshIndicatorExtent;
/// A builder that's called as this sliver's size changes, and as the state
/// changes.
///
/// Can be set to null, in which case nothing will be drawn in the overscrolled
/// space.
///
/// Will not be called when the available space is zero such as before any
/// overscroll.
final RefreshControlIndicatorBuilder? builder;
/// Callback invoked when pulled by [refreshTriggerPullDistance].
///
/// If provided, must return a [Future] which will keep the indicator in the
/// [RefreshIndicatorMode.refresh] state until the [Future] completes.
///
/// Can be null, in which case a single frame of [RefreshIndicatorMode.armed]
/// state will be drawn before going immediately to the [RefreshIndicatorMode.done]
/// where the sliver will start retracting.
final RefreshCallback? onRefresh;
static const double _defaultRefreshTriggerPullDistance = 100.0;
static const double _defaultRefreshIndicatorExtent = 60.0;
/// Retrieve the current state of the CupertinoSliverRefreshControl. The same as the
/// state that gets passed into the [builder] function. Used for testing.
@visibleForTesting
static RefreshIndicatorMode state(BuildContext context) {
final _CupertinoSliverRefreshControlState state = context.findAncestorStateOfType<_CupertinoSliverRefreshControlState>()!;
return state.refreshState;
}
/// Builds a refresh indicator that reflects the standard iOS pull-to-refresh
/// behavior. Specifically, this entails presenting an activity indicator that
/// changes depending on the current refreshState. As the user initially drags
/// down, the indicator will gradually reveal individual ticks until the refresh
/// becomes armed. At this point, the animated activity indicator will begin rotating.
/// Once the refresh has completed, the activity indicator shrinks away as the
/// space allocation animates back to closed.
static Widget buildRefreshIndicator(
BuildContext context,
RefreshIndicatorMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
) {
final double percentageComplete = (pulledExtent / refreshTriggerPullDistance).clamp(0.0, 1.0);
// Place the indicator at the top of the sliver that opens up. Note that we're using
// a Stack/Positioned widget because the CupertinoActivityIndicator does some internal
// translations based on the current size (which grows as the user drags) that makes
// Padding calculations difficult. Rather than be reliant on the internal implementation
// of the activity indicator, the Positioned widget allows us to be explicit where the
// widget gets placed. Also note that the indicator should appear over the top of the
// dragged widget, hence the use of Overflow.visible.
return Center(
child: Stack(
clipBehavior: Clip.none,
children: <Widget>[
Positioned(
top: _kActivityIndicatorMargin,
left: 0.0,
right: 0.0,
child: _buildIndicatorForRefreshState(refreshState, _kActivityIndicatorRadius, percentageComplete),
),
],
),
);
}
static Widget _buildIndicatorForRefreshState(RefreshIndicatorMode refreshState, double radius, double percentageComplete) {
switch (refreshState) {
case RefreshIndicatorMode.drag:
// While we're dragging, we draw individual ticks of the spinner while simultaneously
// easing the opacity in. Note that the opacity curve values here were derived using
// Xcode through inspecting a native app running on iOS 13.5.
const Curve opacityCurve = Interval(0.0, 0.35, curve: Curves.easeInOut);
return Opacity(
opacity: opacityCurve.transform(percentageComplete),
child: CupertinoActivityIndicator.partiallyRevealed(radius: radius, progress: percentageComplete),
);
case RefreshIndicatorMode.armed:
case RefreshIndicatorMode.refresh:
// Once we're armed or performing the refresh, we just show the normal spinner.
return CupertinoActivityIndicator(radius: radius);
case RefreshIndicatorMode.done:
// When the user lets go, the standard transition is to shrink the spinner.
return CupertinoActivityIndicator(radius: radius * percentageComplete);
case RefreshIndicatorMode.inactive:
// Anything else doesn't show anything.
return Container();
}
}
@override
State<CupertinoSliverRefreshControl> createState() => _CupertinoSliverRefreshControlState();
}
class _CupertinoSliverRefreshControlState extends State<CupertinoSliverRefreshControl> {
// Reset the state from done to inactive when only this fraction of the
// original `refreshTriggerPullDistance` is left.
static const double _inactiveResetOverscrollFraction = 0.1;
late RefreshIndicatorMode refreshState;
// [Future] returned by the widget's `onRefresh`.
Future<void>? refreshTask;
// The amount of space available from the inner indicator box's perspective.
//
// The value is the sum of the sliver's layout extent and the overscroll
// (which partially gets transferred into the layout extent when the refresh
// triggers).
//
// The value of latestIndicatorBoxExtent doesn't change when the sliver scrolls
// away without retracting; it is independent from the sliver's scrollOffset.
double latestIndicatorBoxExtent = 0.0;
bool hasSliverLayoutExtent = false;
@override
void initState() {
super.initState();
refreshState = RefreshIndicatorMode.inactive;
}
// A state machine transition calculator. Multiple states can be transitioned
// through per single call.
RefreshIndicatorMode transitionNextState() {
RefreshIndicatorMode nextState;
void goToDone() {
nextState = RefreshIndicatorMode.done;
// Either schedule the RenderSliver to re-layout on the next frame
// when not currently in a frame or schedule it on the next frame.
if (SchedulerBinding.instance!.schedulerPhase == SchedulerPhase.idle) {
setState(() => hasSliverLayoutExtent = false);
} else {
SchedulerBinding.instance!.addPostFrameCallback((Duration timestamp) {
setState(() => hasSliverLayoutExtent = false);
});
}
}
switch (refreshState) {
case RefreshIndicatorMode.inactive:
if (latestIndicatorBoxExtent <= 0) {
return RefreshIndicatorMode.inactive;
} else {
nextState = RefreshIndicatorMode.drag;
}
continue drag;
drag:
case RefreshIndicatorMode.drag:
if (latestIndicatorBoxExtent == 0) {
return RefreshIndicatorMode.inactive;
} else if (latestIndicatorBoxExtent < widget.refreshTriggerPullDistance) {
return RefreshIndicatorMode.drag;
} else {
if (widget.onRefresh != null) {
HapticFeedback.mediumImpact();
// Call onRefresh after this frame finished since the function is
// user supplied and we're always here in the middle of the sliver's
// performLayout.
SchedulerBinding.instance!.addPostFrameCallback((Duration timestamp) {
refreshTask = widget.onRefresh!()..whenComplete(() {
if (mounted) {
setState(() => refreshTask = null);
// Trigger one more transition because by this time, BoxConstraint's
// maxHeight might already be resting at 0 in which case no
// calls to [transitionNextState] will occur anymore and the
// state may be stuck in a non-inactive state.
refreshState = transitionNextState();
}
});
setState(() => hasSliverLayoutExtent = true);
});
}
return RefreshIndicatorMode.armed;
}
case RefreshIndicatorMode.armed:
if (refreshState == RefreshIndicatorMode.armed && refreshTask == null) {
goToDone();
continue done;
}
if (latestIndicatorBoxExtent > widget.refreshIndicatorExtent) {
return RefreshIndicatorMode.armed;
} else {
nextState = RefreshIndicatorMode.refresh;
}
continue refresh;
refresh:
case RefreshIndicatorMode.refresh:
if (refreshTask != null) {
return RefreshIndicatorMode.refresh;
} else {
goToDone();
}
continue done;
done:
case RefreshIndicatorMode.done:
// Let the transition back to inactive trigger before strictly going
// to 0.0 since the last bit of the animation can take some time and
// can feel sluggish if not going all the way back to 0.0 prevented
// a subsequent pull-to-refresh from starting.
if (latestIndicatorBoxExtent >
widget.refreshTriggerPullDistance * _inactiveResetOverscrollFraction) {
return RefreshIndicatorMode.done;
} else {
nextState = RefreshIndicatorMode.inactive;
}
break;
}
return nextState;
}
@override
Widget build(BuildContext context) {
return _CupertinoSliverRefresh(
refreshIndicatorLayoutExtent: widget.refreshIndicatorExtent,
hasLayoutExtent: hasSliverLayoutExtent,
// A LayoutBuilder lets the sliver's layout changes be fed back out to
// its owner to trigger state changes.
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
latestIndicatorBoxExtent = constraints.maxHeight;
refreshState = transitionNextState();
if (widget.builder != null && latestIndicatorBoxExtent > 0) {
return widget.builder!(
context,
refreshState,
latestIndicatorBoxExtent,
widget.refreshTriggerPullDistance,
widget.refreshIndicatorExtent,
);
}
return Container();
},
),
);
}
}