(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,109 @@
import 'package:flutter/foundation.dart';
/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
///
/// Those listeners will be fired when [notifyListeners] fires and will be ignored
/// when [notifySomeListeners] fires.
///
/// The common collection of listeners inherited from [ChangeNotifier] will be fired
/// every time.
class IgnorableChangeNotifier extends ChangeNotifier {
ObserverList<VoidCallback>? _ignorableListeners =
ObserverList<VoidCallback>();
bool _debugAssertNotDisposed() {
assert(() {
if (_ignorableListeners == null) {
AssertionError([
'A $runtimeType was used after being disposed.',
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
]);
}
return true;
}());
return true;
}
@override
bool get hasListeners {
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
}
void addIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.add(listener);
}
void removeIgnorableListener(listener) {
assert(_debugAssertNotDisposed());
_ignorableListeners!.remove(listener);
}
@override
void dispose() {
_ignorableListeners = null;
super.dispose();
}
@protected
@override
@visibleForTesting
void notifyListeners() {
super.notifyListeners();
if (_ignorableListeners != null) {
final List<VoidCallback> localListeners =
List<VoidCallback>.from(_ignorableListeners!);
for (VoidCallback listener in localListeners) {
try {
if (_ignorableListeners!.contains(listener)) {
listener();
}
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'Photoview library',
),
);
}
}
}
}
/// Ignores the ignoreables
@protected
void notifySomeListeners() {
super.notifyListeners();
}
}
/// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has
/// listeners that wont fire when [updateIgnoring] is called.
class IgnorableValueNotifier<T> extends IgnorableChangeNotifier
implements ValueListenable<T> {
IgnorableValueNotifier(this._value);
@override
T get value => _value;
T _value;
set value(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifyListeners();
}
void updateIgnoring(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifySomeListeners();
}
@override
String toString() => '${describeIdentity(this)}($value)';
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/widgets.dart';
/// Data class that holds the attributes that are going to be passed to
/// [PhotoViewImageWrapper]'s [Hero].
class PhotoViewHeroAttributes {
const PhotoViewHeroAttributes({
required this.tag,
this.createRectTween,
this.flightShuttleBuilder,
this.placeholderBuilder,
this.transitionOnUserGestures = false,
});
/// Mirror to [Hero.tag]
final Object tag;
/// Mirror to [Hero.createRectTween]
final CreateRectTween? createRectTween;
/// Mirror to [Hero.flightShuttleBuilder]
final HeroFlightShuttleBuilder? flightShuttleBuilder;
/// Mirror to [Hero.placeholderBuilder]
final HeroPlaceholderBuilder? placeholderBuilder;
/// Mirror to [Hero.transitionOnUserGestures]
final bool transitionOnUserGestures;
}

View File

@@ -0,0 +1,145 @@
import 'dart:math' as math;
import 'dart:ui' show Size;
import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart";
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
double getScaleForScaleState(
PhotoViewScaleState scaleState,
ScaleBoundaries scaleBoundaries,
) {
switch (scaleState) {
case PhotoViewScaleState.initial:
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
case PhotoViewScaleState.covering:
return _clampSize(
_scaleForCovering(
scaleBoundaries.outerSize,
scaleBoundaries.childSize,
),
scaleBoundaries,
);
case PhotoViewScaleState.originalSize:
return _clampSize(1.0, scaleBoundaries);
// Will never be reached
default:
return 0;
}
}
/// Internal class to wraps custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and teh child.
class ScaleBoundaries {
const ScaleBoundaries(
this._minScale,
this._maxScale,
this._initialScale,
this.outerSize,
this.childSize,
);
final dynamic _minScale;
final dynamic _maxScale;
final dynamic _initialScale;
final Size outerSize;
final Size childSize;
double get minScale {
assert(_minScale is double || _minScale is PhotoViewComputedScale);
if (_minScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
if (_minScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
}
assert(_minScale >= 0.0);
return _minScale;
}
double get maxScale {
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
if (_maxScale == PhotoViewComputedScale.contained) {
return (_scaleForContained(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
if (_maxScale == PhotoViewComputedScale.covered) {
return (_scaleForCovering(outerSize, childSize) *
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier)
.clamp(minScale, double.infinity);
}
return _maxScale.clamp(minScale, double.infinity);
}
double get initialScale {
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
if (_initialScale == PhotoViewComputedScale.contained) {
return _scaleForContained(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
if (_initialScale == PhotoViewComputedScale.covered) {
return _scaleForCovering(outerSize, childSize) *
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
.multiplier;
}
return _initialScale.clamp(minScale, maxScale);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScaleBoundaries &&
runtimeType == other.runtimeType &&
_minScale == other._minScale &&
_maxScale == other._maxScale &&
_initialScale == other._initialScale &&
outerSize == other.outerSize &&
childSize == other.childSize;
@override
int get hashCode =>
_minScale.hashCode ^
_maxScale.hashCode ^
_initialScale.hashCode ^
outerSize.hashCode ^
childSize.hashCode;
}
double _scaleForContained(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _scaleForCovering(Size size, Size childSize) {
final double imageWidth = childSize.width;
final double imageHeight = childSize.height;
final double screenWidth = size.width;
final double screenHeight = size.height;
return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
}
double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
}
/// Simple class to store a min and a max value
class CornersRange {
const CornersRange(this.min, this.max);
final double min;
final double max;
}