Skip to content

Commit

Permalink
Refine animated activity behavior on content size changes (#240)
Browse files Browse the repository at this point in the history
## Summary (check all that apply)

- [x] Modified / added code
- [x] Modified / added tests
- [ ] Modified / added examples
- [x] Modified / added others (pubspec.yaml, workflows, etc...)
- [ ] Updated README
- [ ] Contains breaking changes
  - [ ] Created / updated migration guide
- [ ] Incremented version number
  - [ ] Updated CHANGELOG
  • Loading branch information
fujidaiti authored Sep 4, 2024
1 parent 9cc6e2f commit 7ba05f7
Show file tree
Hide file tree
Showing 6 changed files with 1,433 additions and 30 deletions.
6 changes: 6 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
targets:
$default:
builders:
mockito|mockBuilder:
generate_for:
- test/src/*.dart
104 changes: 74 additions & 30 deletions lib/src/foundation/sheet_activity.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';

import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
Expand Down Expand Up @@ -136,36 +137,67 @@ abstract class SheetActivity<T extends SheetExtent> {
}
}

/// An activity that animates the [SheetExtent]'s `pixels` to a destination
/// position resolved by [destination], using the specified [curve] and
/// [duration].
///
/// This activity accepts the destination position as an [Extent], allowing
/// the concrete end position (in pixels) to be adjusted during the animation
/// in response to viewport changes, such as the appearance of the keyboard.
///
/// When the end position changes, this activity updates the [SheetExtent]'s
/// `pixels` to maintain the sheet's visual position (referred to as *p*).
/// In subsequent frames, the animated position is linearly interpolated
/// between *p* and the new destination.
@internal
class AnimatedSheetActivity extends SheetActivity
with ControlledSheetActivityMixin {
AnimatedSheetActivity({
required this.destination,
required this.duration,
required this.curve,
}) : assert(duration > Duration.zero);
}) : _effectiveCurve = curve,
assert(duration > Duration.zero);

final Extent destination;
final Duration duration;
final Curve curve;

late double _startPixels;
Curve _effectiveCurve;

@override
void init(SheetExtent delegate) {
super.init(delegate);
_startPixels = owner.metrics.pixels;
}

@override
AnimationController createAnimationController() {
return AnimationController.unbounded(
value: owner.metrics.pixels,
vsync: owner.context.vsync,
);
return AnimationController.unbounded(vsync: owner.context.vsync);
}

@override
TickerFuture onAnimationStart() {
return controller.animateTo(
destination.resolve(owner.metrics.contentSize),
1.0,
duration: duration,
curve: curve,
);
}

@override
void onAnimationTick() {
// The baseline may change during the animation, so we need to
// interpolate the current pixels in absolute coordinates. This ensures
// visual consistency regardless of baseline changes.
final endPixels = destination.resolve(owner.metrics.contentSize);
final progress = _effectiveCurve.transform(controller.value);
owner
..setPixels(lerpDouble(_startPixels, endPixels, progress)!)
..didUpdateMetrics();
}

@override
void onAnimationEnd() {
owner.goBallistic(0);
Expand All @@ -177,25 +209,30 @@ class AnimatedSheetActivity extends SheetActivity
Size? oldViewportSize,
EdgeInsets? oldViewportInsets,
) {
// 1. Appends the delta of the bottom inset (typically the keyboard height)
// Appends the delta of the bottom inset (typically the keyboard height)
// to keep the visual sheet position unchanged.
final newInsets = owner.metrics.viewportInsets;
final oldInsets = oldViewportInsets ?? newInsets;
final deltaInsetBottom = newInsets.bottom - oldInsets.bottom;
final newPixels = owner.metrics.pixels - deltaInsetBottom;
owner
..setPixels(owner.metrics.pixels - deltaInsetBottom)
..setPixels(newPixels)
..didUpdateMetrics();

// 2. If the animation is still running, we start a new linear animation
// to bring the sheet position to the recalculated final position in the
// remaining duration. We use a linear curve here because starting a curved
// animation in the middle of another curved animation tends to look jerky.
final newDestination = destination.resolve(owner.metrics.contentSize);
final elapsedDuration = controller.lastElapsedDuration ?? duration;
if (newDestination != controller.upperBound && elapsedDuration < duration) {
final carriedDuration = duration - elapsedDuration;
owner.animateTo(destination,
duration: carriedDuration, curve: Curves.linear);
if (oldContentSize == null) {
return;
}

final oldEndPixels = destination.resolve(oldContentSize);
final newEndPixels = destination.resolve(owner.metrics.contentSize);
final progress = controller.value;
if (oldEndPixels != newEndPixels && progress < 1) {
// The gradient of the line passing through the point
// (t=progress, newPixels) and (t=1.0, newEndPixels).
final gradient = (newEndPixels - newPixels) / (1 - progress);
// The new start position is the intersection of that line with t=0.
_startPixels = newEndPixels - gradient;
_effectiveCurve = Curves.linear;
}
}
}
Expand All @@ -208,6 +245,13 @@ class BallisticSheetActivity extends SheetActivity
});

final Simulation simulation;
late double _lastAnimatedValue;

@override
void init(SheetExtent delegate) {
super.init(delegate);
_lastAnimatedValue = controller.value;
}

@override
AnimationController createAnimationController() {
Expand All @@ -219,6 +263,17 @@ class BallisticSheetActivity extends SheetActivity
return controller.animateWith(simulation);
}

@override
void onAnimationTick() {
if (mounted) {
final oldPixels = owner.metrics.pixels;
owner
..setPixels(oldPixels + controller.value - _lastAnimatedValue)
..didUpdateMetrics();
_lastAnimatedValue = controller.value;
}
}

@override
void onAnimationEnd() {
owner.goBallistic(0);
Expand Down Expand Up @@ -294,14 +349,14 @@ class DragSheetActivity extends SheetActivity
@optionalTypeArgs
mixin ControlledSheetActivityMixin<T extends SheetExtent> on SheetActivity<T> {
late final AnimationController controller;
late double _lastAnimatedValue;

final _completer = Completer<void>();
Future<void> get done => _completer.future;

@factory
AnimationController createAnimationController();
TickerFuture onAnimationStart();
void onAnimationTick();
void onAnimationEnd() {}

@override
Expand All @@ -316,17 +371,6 @@ mixin ControlledSheetActivityMixin<T extends SheetExtent> on SheetActivity<T> {
controller = createAnimationController()..addListener(onAnimationTick);
// Won't trigger if we dispose 'animation' first.
onAnimationStart().whenComplete(onAnimationEnd);
_lastAnimatedValue = controller.value;
}

void onAnimationTick() {
if (mounted) {
final oldPixels = owner.metrics.pixels;
owner
..setPixels(oldPixels + controller.value - _lastAnimatedValue)
..didUpdateMetrics();
_lastAnimatedValue = controller.value;
}
}

@override
Expand Down
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ dependencies:
meta: ^1.9.1

dev_dependencies:
build_runner: ^2.4.9
flutter_test:
sdk: flutter
mockito: ^5.4.4
pedantic_mono: ^1.23.0
148 changes: 148 additions & 0 deletions test/foundation/sheet_activity_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import 'package:flutter/animation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:smooth_sheets/src/foundation/sheet_activity.dart';
import 'package:smooth_sheets/src/foundation/sheet_extent.dart';

import '../src/stubbing.dart';

class _TestAnimatedSheetActivity extends AnimatedSheetActivity {
_TestAnimatedSheetActivity({
required AnimationController controller,
required super.destination,
required super.duration,
required super.curve,
}) : _controller = controller;

final AnimationController _controller;

@override
AnimationController createAnimationController() {
return _controller;
}
}

void main() {
group('AnimatedSheetActivity', () {
late MockSheetExtent owner;
late MockAnimationController controller;

setUp(() {
owner = MockSheetExtent();
controller = MockAnimationController();

when(
controller.animateTo(
any,
duration: anyNamed('duration'),
curve: anyNamed('curve'),
),
).thenAnswer((_) => MockTickerFuture());
});

test('should animate to the destination', () {
when(owner.metrics).thenReturn(
const SheetMetrics(
pixels: 300,
minPixels: 300,
maxPixels: 700,
contentSize: Size(400, 700),
viewportSize: Size(400, 900),
viewportInsets: EdgeInsets.zero,
),
);

final activity = _TestAnimatedSheetActivity(
controller: controller,
destination: const Extent.pixels(700),
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
)..init(owner);

verify(
controller.animateTo(
1,
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
),
);

when(controller.value).thenReturn(0.0);
activity.onAnimationTick();
verify(owner.setPixels(300));

when(controller.value).thenReturn(0.5);
activity.onAnimationTick();
verify(owner.setPixels(500));

when(controller.value).thenReturn(1.0);
activity.onAnimationTick();
verify(owner.setPixels(700));
});

test('should absorb viewport changes', () {
var metrics = const SheetMetrics(
pixels: 300,
minPixels: 300,
maxPixels: 900,
contentSize: Size(400, 900),
viewportSize: Size(400, 900),
viewportInsets: EdgeInsets.zero,
);
when(owner.metrics).thenAnswer((_) => metrics);
when(owner.setPixels(any)).thenAnswer((invocation) {
final pixels = invocation.positionalArguments[0] as double;
metrics = metrics.copyWith(pixels: pixels);
});

final activity = _TestAnimatedSheetActivity(
controller: controller,
destination: const Extent.proportional(1),
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
)..init(owner);

when(controller.value).thenReturn(0.0);
activity.onAnimationTick();
verify(owner.setPixels(300));

when(controller.value).thenReturn(0.25);
activity.onAnimationTick();
verify(owner.setPixels(450));

// The following lines simulate a viewport change, in which:
// 1. The viewport's bottom inset increases, simulating the
// appearance of an on-screen keyboard.
// 2. The content size then reduces to avoid overlapping with the
// increased bottom inset.
// This scenario mimics the behavior when opening a keyboard
// on a sheet that uses SheetContentScaffold.
final oldViewportInsets = metrics.viewportInsets;
final oldContentSize = metrics.contentSize;
metrics = metrics.copyWith(
maxPixels: 850,
viewportInsets: const EdgeInsets.only(bottom: 50),
contentSize: const Size(400, 850),
);
activity.didChangeViewportDimensions(null, oldViewportInsets);
activity.didChangeContentSize(oldContentSize);
activity.didFinalizeDimensions(oldContentSize, null, oldViewportInsets);
verify(owner.setPixels(400));
expect(metrics.viewPixels, 450,
reason: 'Visual position should not change when viewport changes.');

when(controller.value).thenReturn(0.5);
activity.onAnimationTick();
verify(owner.setPixels(550));

when(controller.value).thenReturn(0.75);
activity.onAnimationTick();
verify(owner.setPixels(700));

when(controller.value).thenReturn(1.0);
activity.onAnimationTick();
verify(owner.setPixels(850));
});
});
}
10 changes: 10 additions & 0 deletions test/src/stubbing.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'package:flutter/animation.dart';
import 'package:mockito/annotations.dart';
import 'package:smooth_sheets/src/foundation/sheet_extent.dart';

@GenerateNiceMocks([
MockSpec<SheetExtent>(),
MockSpec<AnimationController>(),
MockSpec<TickerFuture>(),
])
export 'stubbing.mocks.dart';
Loading

0 comments on commit 7ba05f7

Please sign in to comment.