(fix)mobile: Improve the gallery to improve scale, double tap, and swipe gesture detection (#1502)

* photoviewgallery

* stiffer scrolling to react more like google photos

* adds a dx threshhold for the swipe/up down from the original dropped point

* stopped wrapping imageview in gallery viewer to avoid the double photoview issue. breaks imageview page pinch-to-zoom, so i need to fix that for other callers

* refactors gallery view to use remoteimage directly and breaks imageviewpage

* removed image_viewer_page

* adds minscale

* adds photo_view to repository

* double tap to zoom out with hacked commit

* double tapping!

* got up and down swipe gestures working

* fixed wrong cache and headers in image providers

* fixed image quality and added videos back in

* local loading asset image fix

* precaches images

* fixes lint errors

* deleted remote_photo_view and more linters

* fixes scale

* load preview and load original

* precache does original / preview as well

* refactored image providers to nice functions and added JPEG thumbnail format to remote image thumbnail lookup

* moved photo_view to shared/ui/

* three stage loading with webp and fixes some thumbnail fits

* fixed local thumbnail

* fixed paging in iOS
This commit is contained in:
martyfuhry
2023-02-01 11:59:34 -05:00
committed by GitHub
parent 391bf052e4
commit 02f5a86ee9
22 changed files with 3460 additions and 448 deletions

View File

@@ -0,0 +1,291 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
/// The interface in which controllers will be implemented.
///
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
///
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
///
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
///
/// The default implementation used by [PhotoView] is [PhotoViewController].
///
/// This was created to allow customization (you can create your own controller class)
///
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
/// [ScaleStateListener is responsible for tat value now
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The output for state/value updates. Usually a broadcast [Stream]
Stream<T> get outputStateStream;
/// The state value before the last change or the initial state if the state has not been changed.
late T prevValue;
/// The actual state value
late T value;
/// Resets the state to the initial value;
void reset();
/// Closes streams and removes eventual listeners.
void dispose();
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void addIgnorableListener(VoidCallback callback);
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputStateStream]
void removeIgnorableListener(VoidCallback callback);
/// The position of the image in the screen given its offset after pan gestures.
late Offset position;
/// The scale factor to transform the child (image or a customChild).
late double? scale;
/// Nevermind this method :D, look away
void setScaleInvisibly(double? scale);
/// The rotation factor to transform the child (image or a customChild).
late double rotation;
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
Offset? rotationFocusPoint;
/// Update multiple fields of the state with only one update streamed.
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
});
}
/// The state value stored and streamed by [PhotoViewController].
@immutable
class PhotoViewControllerValue {
const PhotoViewControllerValue({
required this.position,
required this.scale,
required this.rotation,
required this.rotationFocusPoint,
});
final Offset position;
final double? scale;
final double rotation;
final Offset? rotationFocusPoint;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PhotoViewControllerValue &&
runtimeType == other.runtimeType &&
position == other.position &&
scale == other.scale &&
rotation == other.rotation &&
rotationFocusPoint == other.rotationFocusPoint;
@override
int get hashCode =>
position.hashCode ^
scale.hashCode ^
rotation.hashCode ^
rotationFocusPoint.hashCode;
@override
String toString() {
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
}
}
/// The default implementation of [PhotoViewControllerBase].
///
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
/// updates via [outputStateStream].
///
/// For details of fields and methods, check [PhotoViewControllerBase].
///
class PhotoViewController
implements PhotoViewControllerBase<PhotoViewControllerValue> {
PhotoViewController({
Offset initialPosition = Offset.zero,
double initialRotation = 0.0,
double? initialScale,
}) : _valueNotifier = IgnorableValueNotifier(
PhotoViewControllerValue(
position: initialPosition,
rotation: initialRotation,
scale: initialScale,
rotationFocusPoint: null,
),
),
super() {
initial = value;
prevValue = initial;
_valueNotifier.addListener(_changeListener);
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
_outputCtrl.sink.add(initial);
}
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
late PhotoViewControllerValue initial;
late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
@override
late PhotoViewControllerValue prevValue;
@override
void reset() {
value = initial;
}
void _changeListener() {
_outputCtrl.sink.add(value);
}
@override
void addIgnorableListener(VoidCallback callback) {
_valueNotifier.addIgnorableListener(callback);
}
@override
void removeIgnorableListener(VoidCallback callback) {
_valueNotifier.removeIgnorableListener(callback);
}
@override
void dispose() {
_outputCtrl.close();
_valueNotifier.dispose();
}
@override
set position(Offset position) {
if (value.position == position) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset get position => value.position;
@override
set scale(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double? get scale => value.scale;
@override
void setScaleInvisibly(double? scale) {
if (value.scale == scale) {
return;
}
prevValue = value;
_valueNotifier.updateIgnoring(
PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
),
);
}
@override
set rotation(double rotation) {
if (value.rotation == rotation) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
double get rotation => value.rotation;
@override
set rotationFocusPoint(Offset? rotationFocusPoint) {
if (value.rotationFocusPoint == rotationFocusPoint) {
return;
}
prevValue = value;
value = PhotoViewControllerValue(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
@override
Offset? get rotationFocusPoint => value.rotationFocusPoint;
@override
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
prevValue = value;
value = PhotoViewControllerValue(
position: position ?? value.position,
scale: scale ?? value.scale,
rotation: rotation ?? value.rotation,
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
);
}
@override
PhotoViewControllerValue get value => _valueNotifier.value;
@override
set value(PhotoViewControllerValue newValue) {
if (_valueNotifier.value == newValue) {
return;
}
_valueNotifier.value = newValue;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
show
PhotoViewControllerBase,
PhotoViewScaleState,
PhotoViewScaleStateController,
ScaleStateCycle;
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
/// A class to hold internal layout logic to sync both controller states
///
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
PhotoViewControllerBase get controller => widget.controller;
PhotoViewScaleStateController get scaleStateController =>
widget.scaleStateController;
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
Alignment get basePosition => widget.basePosition;
Function(double prevScale, double nextScale)? _animateScale;
/// Mark if scale need recalculation, useful for scale boundaries changes.
bool markNeedsScaleRecalc = true;
void initDelegate() {
controller.addIgnorableListener(_blindScaleListener);
scaleStateController.addIgnorableListener(_blindScaleStateListener);
}
void _blindScaleStateListener() {
if (!scaleStateController.hasChanged) {
return;
}
if (_animateScale == null || scaleStateController.isZooming) {
controller.setScaleInvisibly(scale);
return;
}
final double prevScale = controller.scale ??
getScaleForScaleState(
scaleStateController.prevScaleState,
scaleBoundaries,
);
final double nextScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
_animateScale!(prevScale, nextScale);
}
void addAnimateOnScaleStateUpdate(
void Function(double prevScale, double nextScale) animateScale,
) {
_animateScale = animateScale;
}
void _blindScaleListener() {
if (!widget.enablePanAlways) {
controller.position = clampPosition();
}
if (controller.scale == controller.prevValue.scale) {
return;
}
final PhotoViewScaleState newScaleState =
(scale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
scaleStateController.setInvisibly(newScaleState);
}
Offset get position => controller.position;
double get scale {
// for figuring out initial scale
final needsRecalc = markNeedsScaleRecalc &&
!scaleStateController.scaleState.isScaleStateZooming;
final scaleExistsOnController = controller.scale != null;
if (needsRecalc || !scaleExistsOnController) {
final newScale = getScaleForScaleState(
scaleStateController.scaleState,
scaleBoundaries,
);
markNeedsScaleRecalc = false;
scale = newScale;
return newScale;
}
return controller.scale!;
}
set scale(double scale) => controller.setScaleInvisibly(scale);
void updateMultiple({
Offset? position,
double? scale,
double? rotation,
Offset? rotationFocusPoint,
}) {
controller.updateMultiple(
position: position,
scale: scale,
rotation: rotation,
rotationFocusPoint: rotationFocusPoint,
);
}
void updateScaleStateFromNewScale(double newScale) {
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
if (scale != scaleBoundaries.initialScale) {
newScaleState = (newScale > scaleBoundaries.initialScale)
? PhotoViewScaleState.zoomedIn
: PhotoViewScaleState.zoomedOut;
}
scaleStateController.setInvisibly(newScaleState);
}
void nextScaleState() {
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
if (scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut) {
scaleStateController.scaleState = scaleStateCycle(scaleState);
return;
}
final double originalScale = getScaleForScaleState(
scaleState,
scaleBoundaries,
);
double prevScale = originalScale;
PhotoViewScaleState prevScaleState = scaleState;
double nextScale = originalScale;
PhotoViewScaleState nextScaleState = scaleState;
do {
prevScale = nextScale;
prevScaleState = nextScaleState;
nextScaleState = scaleStateCycle(prevScaleState);
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
} while (prevScale == nextScale && scaleState != nextScaleState);
if (originalScale == nextScale) {
return;
}
scaleStateController.scaleState = nextScaleState;
}
CornersRange cornersX({double? scale}) {
final double s = scale ?? this.scale;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double positionX = basePosition.x;
final double widthDiff = computedWidth - screenWidth;
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
return CornersRange(minX, maxX);
}
CornersRange cornersY({double? scale}) {
final double s = scale ?? this.scale;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenHeight = scaleBoundaries.outerSize.height;
final double positionY = basePosition.y;
final double heightDiff = computedHeight - screenHeight;
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
return CornersRange(minY, maxY);
}
Offset clampPosition({Offset? position, double? scale}) {
final double s = scale ?? this.scale;
final Offset p = position ?? this.position;
final double computedWidth = scaleBoundaries.childSize.width * s;
final double computedHeight = scaleBoundaries.childSize.height * s;
final double screenWidth = scaleBoundaries.outerSize.width;
final double screenHeight = scaleBoundaries.outerSize.height;
double finalX = 0.0;
if (screenWidth < computedWidth) {
final cornersX = this.cornersX(scale: s);
finalX = p.dx.clamp(cornersX.min, cornersX.max);
}
double finalY = 0.0;
if (screenHeight < computedHeight) {
final cornersY = this.cornersY(scale: s);
finalY = p.dy.clamp(cornersY.min, cornersY.max);
}
return Offset(finalX, finalY);
}
@override
void dispose() {
_animateScale = null;
controller.removeIgnorableListener(_blindScaleListener);
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
super.dispose();
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/widgets.dart' show VoidCallback;
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
/// A controller responsible only by [scaleState].
///
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
/// This cycle is triggered by the "doubleTap" gesture.
///
/// Any change in its [scaleState] should animate the scale of image/content.
///
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
///
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
///
class PhotoViewScaleStateController {
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
IgnorableValueNotifier(PhotoViewScaleState.initial)
..addListener(_scaleStateChangeListener);
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
StreamController<PhotoViewScaleState>.broadcast()
..sink.add(PhotoViewScaleState.initial);
/// The output for state/value updates
Stream<PhotoViewScaleState> get outputScaleStateStream =>
_outputScaleStateCtrl.stream;
/// The state value before the last change or the initial state if the state has not been changed.
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
/// The actual state value
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
/// Updates scaleState and notify all listeners (and the stream)
set scaleState(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.value = newValue;
}
/// Checks if its actual value is different than previousValue
bool get hasChanged => prevScaleState != scaleState;
/// Check if is `zoomedIn` & `zoomedOut`
bool get isZooming =>
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.zoomedOut;
/// Resets the state to the initial value;
void reset() {
prevScaleState = scaleState;
scaleState = PhotoViewScaleState.initial;
}
/// Closes streams and removes eventual listeners
void dispose() {
_outputScaleStateCtrl.close();
_scaleStateNotifier.dispose();
}
/// Nevermind this method :D, look away
/// Seriously: It is used to change scale state without trigging updates on the []
void setInvisibly(PhotoViewScaleState newValue) {
if (_scaleStateNotifier.value == newValue) {
return;
}
prevScaleState = _scaleStateNotifier.value;
_scaleStateNotifier.updateIgnoring(newValue);
}
void _scaleStateChangeListener() {
_outputScaleStateCtrl.sink.add(scaleState);
}
/// Add a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void addIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.addIgnorableListener(callback);
}
/// Remove a listener that will ignore updates made internally
///
/// Since it is made for internal use, it is not performatic to use more than one
/// listener. Prefer [outputScaleStateStream]
void removeIgnorableListener(VoidCallback callback) {
_scaleStateNotifier.removeIgnorableListener(callback);
}
}