reworked and integrated photo_view package, fixed double tap zoom focus
This commit is contained in:
parent
38cbe7fc2e
commit
05496da344
38 changed files with 1599 additions and 287 deletions
|
@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
## Known Issues
|
||||
|
||||
- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82))
|
||||
- performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493))
|
||||
- SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31))
|
||||
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))
|
||||
|
|
|
@ -125,7 +125,5 @@ class RegionProviderKey {
|
|||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -116,7 +116,5 @@ class ThumbnailProviderKey {
|
|||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -80,5 +80,5 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
int get hashCode => hashValues(uri, scale);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -54,5 +54,5 @@ class UriPicture extends PictureProvider<UriPicture> {
|
|||
int get hashCode => hashValues(uri, colorFilter);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}';
|
||||
}
|
||||
|
|
|
@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, album);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{album=$album}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
|
||||
}
|
||||
|
|
|
@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, level, _location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||
|
||||
// U+0041 Latin Capital letter A
|
||||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||
|
|
|
@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, mime);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||
}
|
||||
|
|
|
@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, query);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{query=$query}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
|
||||
}
|
||||
|
|
|
@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, tag);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||
}
|
||||
|
|
|
@ -134,9 +134,7 @@ class ImageEntry {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageEntry{uri=$uri, path=$path}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
|
||||
|
||||
set path(String path) {
|
||||
_path = path;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geocoder/model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
@ -23,9 +24,7 @@ class DateMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
|
||||
}
|
||||
|
||||
class CatalogMetadata {
|
||||
|
@ -117,9 +116,7 @@ class CatalogMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
|
||||
class OverlayMetadata {
|
||||
|
@ -150,9 +147,7 @@ class OverlayMetadata {
|
|||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
|
||||
class AddressDetails {
|
||||
|
@ -200,9 +195,7 @@ class AddressDetails {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
@ -237,7 +230,5 @@ class FavouriteRow {
|
|||
int get hashCode => hashValues(contentId, path);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FavouriteRow{contentId=$contentId, path=$path}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
|
||||
}
|
||||
|
|
|
@ -300,9 +300,7 @@ class ImageOpEvent {
|
|||
int get hashCode => hashValues(success, uri);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageOpEvent{success=$success, uri=$uri}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
|
@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
|
|
|
@ -239,12 +239,6 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Photo View',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/renancaraujo/photo_view/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/renancaraujo/photo_view',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Printing',
|
||||
license: 'Apache 2.0',
|
||||
|
|
|
@ -4,9 +4,9 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
106
lib/widgets/common/magnifier/controller/controller.dart
Normal file
106
lib/widgets/common/magnifier/controller/controller.dart
Normal file
|
@ -0,0 +1,106 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MagnifierController {
|
||||
MagnifierController({
|
||||
Offset initialPosition = Offset.zero,
|
||||
}) : _valueNotifier = ValueNotifier(
|
||||
MagnifierState(
|
||||
position: initialPosition,
|
||||
scale: null,
|
||||
source: ChangeSource.internal,
|
||||
),
|
||||
),
|
||||
super() {
|
||||
initial = value;
|
||||
prevValue = initial;
|
||||
|
||||
_valueNotifier.addListener(_changeListener);
|
||||
_outputCtrl = StreamController<MagnifierState>.broadcast();
|
||||
_outputCtrl.sink.add(initial);
|
||||
}
|
||||
|
||||
final ValueNotifier<MagnifierState> _valueNotifier;
|
||||
|
||||
MagnifierState initial;
|
||||
|
||||
StreamController<MagnifierState> _outputCtrl;
|
||||
|
||||
/// The output for state/value updates. Usually a broadcast [Stream]
|
||||
Stream<MagnifierState> get outputStateStream => _outputCtrl.stream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
MagnifierState prevValue;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset() {
|
||||
_setValue(initial);
|
||||
}
|
||||
|
||||
void _changeListener() {
|
||||
_outputCtrl.sink.add(value);
|
||||
}
|
||||
|
||||
/// Closes streams and removes eventual listeners.
|
||||
void dispose() {
|
||||
_outputCtrl.close();
|
||||
_valueNotifier.dispose();
|
||||
}
|
||||
|
||||
void setPosition(Offset position, ChangeSource source) {
|
||||
// debugPrint('$runtimeType setPosition position=$position, source=$source');
|
||||
if (value.position == position) return;
|
||||
|
||||
prevValue = value;
|
||||
_setValue(MagnifierState(
|
||||
position: position,
|
||||
scale: scale,
|
||||
source: source,
|
||||
));
|
||||
}
|
||||
|
||||
/// The position of the image in the screen given its offset after pan gestures.
|
||||
Offset get position => value.position;
|
||||
|
||||
void setScale(double scale, ChangeSource source) {
|
||||
// debugPrint('$runtimeType setScale scale=$scale source=$source');
|
||||
if (value.scale == scale) return;
|
||||
|
||||
prevValue = value;
|
||||
_setValue(MagnifierState(
|
||||
position: position,
|
||||
scale: scale,
|
||||
source: source,
|
||||
));
|
||||
}
|
||||
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
double get scale => value.scale;
|
||||
|
||||
/// Update multiple fields of the state with only one update streamed.
|
||||
void updateMultiple({
|
||||
Offset position,
|
||||
double scale,
|
||||
@required ChangeSource source,
|
||||
}) {
|
||||
// debugPrint('$runtimeType updateMultiple position=$position scale=$scale, source=$source');
|
||||
prevValue = value;
|
||||
_setValue(MagnifierState(
|
||||
position: position ?? value.position,
|
||||
scale: scale ?? value.scale,
|
||||
source: source,
|
||||
));
|
||||
}
|
||||
|
||||
/// The actual state value
|
||||
MagnifierState get value => _valueNotifier.value;
|
||||
|
||||
void _setValue(MagnifierState newValue) {
|
||||
// debugPrint('$runtimeType setValue value=$newValue');
|
||||
if (_valueNotifier.value == newValue) return;
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
}
|
226
lib/widgets/common/magnifier/controller/controller_delegate.dart
Normal file
226
lib/widgets/common/magnifier/controller/controller_delegate.dart
Normal file
|
@ -0,0 +1,226 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/core/core.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:flutter/widgets.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 MagnifierControllerDelegate on State<MagnifierCore> {
|
||||
MagnifierController get controller => widget.controller;
|
||||
|
||||
MagnifierScaleStateController get scaleStateController => widget.scaleStateController;
|
||||
|
||||
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
|
||||
|
||||
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
|
||||
|
||||
Alignment get basePosition => Alignment.center;
|
||||
|
||||
Function(double prevScale, double nextScale, Offset nextPosition) _animateScale;
|
||||
|
||||
/// Mark if scale need recalculation, useful for scale boundaries changes.
|
||||
bool markNeedsScaleRecalc = true;
|
||||
|
||||
final List<StreamSubscription> _streamSubs = [];
|
||||
|
||||
void startListeners() {
|
||||
_streamSubs.add(controller.outputStateStream.listen(_onMagnifierStateChange));
|
||||
_streamSubs.add(scaleStateController.scaleStateChangeStream.listen(_onScaleStateChange));
|
||||
}
|
||||
|
||||
void _onScaleStateChange(ScaleStateChange scaleStateChange) {
|
||||
if (scaleStateChange.source == ChangeSource.internal) return;
|
||||
if (!scaleStateController.hasChanged) return;
|
||||
|
||||
if (_animateScale == null || scaleStateController.isZooming) {
|
||||
controller.setScale(scale, scaleStateChange.source);
|
||||
return;
|
||||
}
|
||||
|
||||
final nextScaleState = scaleStateChange.state;
|
||||
final nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
|
||||
var nextPosition = Offset.zero;
|
||||
if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) {
|
||||
final childFocalPoint = scaleStateChange.childFocalPoint;
|
||||
if (childFocalPoint != null) {
|
||||
final childCenter = scaleBoundaries.childSize.center(Offset.zero);
|
||||
nextPosition = (childCenter - childFocalPoint) * nextScale;
|
||||
}
|
||||
}
|
||||
|
||||
final prevScale = controller.scale ?? getScaleForScaleState(scaleStateController.prevScaleState.state, scaleBoundaries);
|
||||
_animateScale(prevScale, nextScale, nextPosition);
|
||||
}
|
||||
|
||||
void addAnimateOnScaleStateUpdate(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) {
|
||||
_animateScale = animateScale;
|
||||
}
|
||||
|
||||
void _onMagnifierStateChange(MagnifierState state) {
|
||||
controller.setPosition(clampPosition(), state.source);
|
||||
if (controller.scale == controller.prevValue.scale) return;
|
||||
|
||||
if (state.source == ChangeSource.internal || state.source == ChangeSource.animation) return;
|
||||
final newScaleState = (scale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut;
|
||||
scaleStateController.setScaleState(newScaleState, state.source);
|
||||
}
|
||||
|
||||
Offset get position => controller.position;
|
||||
|
||||
double get scale {
|
||||
final scaleState = scaleStateController.scaleState.state;
|
||||
final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut);
|
||||
final scaleExistsOnController = controller.scale != null;
|
||||
if (needsRecalc || !scaleExistsOnController) {
|
||||
final newScale = getScaleForScaleState(scaleState, scaleBoundaries);
|
||||
markNeedsScaleRecalc = false;
|
||||
setScale(newScale, ChangeSource.internal);
|
||||
return newScale;
|
||||
}
|
||||
return controller.scale;
|
||||
}
|
||||
|
||||
void setScale(double scale, ChangeSource source) => controller.setScale(scale, source);
|
||||
|
||||
void updateMultiple({
|
||||
Offset position,
|
||||
double scale,
|
||||
@required ChangeSource source,
|
||||
}) {
|
||||
controller.updateMultiple(position: position, scale: scale, source: source);
|
||||
}
|
||||
|
||||
void updateScaleStateFromNewScale(double newScale, ChangeSource source) {
|
||||
// debugPrint('updateScaleStateFromNewScale scale=$newScale, source=$source');
|
||||
var newScaleState = ScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut;
|
||||
}
|
||||
scaleStateController.setScaleState(newScaleState, source);
|
||||
}
|
||||
|
||||
void nextScaleState(ChangeSource source, {Offset childFocalPoint}) {
|
||||
// debugPrint('$runtimeType nextScaleState source=$source');
|
||||
final scaleState = scaleStateController.scaleState.state;
|
||||
if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) {
|
||||
scaleStateController.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint);
|
||||
return;
|
||||
}
|
||||
final originalScale = getScaleForScaleState(
|
||||
scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
var prevScale = originalScale;
|
||||
var prevScaleState = scaleState;
|
||||
var nextScale = originalScale;
|
||||
var nextScaleState = scaleState;
|
||||
|
||||
do {
|
||||
prevScale = nextScale;
|
||||
prevScaleState = nextScaleState;
|
||||
nextScaleState = scaleStateCycle(prevScaleState);
|
||||
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
|
||||
} while (prevScale == nextScale && scaleState != nextScaleState);
|
||||
|
||||
if (originalScale == nextScale) return;
|
||||
scaleStateController.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint);
|
||||
}
|
||||
|
||||
CornersRange cornersX({double scale}) {
|
||||
final _scale = scale ?? this.scale;
|
||||
|
||||
final computedWidth = scaleBoundaries.childSize.width * _scale;
|
||||
final screenWidth = scaleBoundaries.viewportSize.width;
|
||||
|
||||
final positionX = basePosition.x;
|
||||
final widthDiff = computedWidth - screenWidth;
|
||||
|
||||
final minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
|
||||
final maxX = ((positionX + 1).abs() / 2) * widthDiff;
|
||||
return CornersRange(minX, maxX);
|
||||
}
|
||||
|
||||
CornersRange cornersY({double scale}) {
|
||||
final _scale = scale ?? this.scale;
|
||||
|
||||
final computedHeight = scaleBoundaries.childSize.height * _scale;
|
||||
final screenHeight = scaleBoundaries.viewportSize.height;
|
||||
|
||||
final positionY = basePosition.y;
|
||||
final heightDiff = computedHeight - screenHeight;
|
||||
|
||||
final minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
|
||||
final maxY = ((positionY + 1).abs() / 2) * heightDiff;
|
||||
return CornersRange(minY, maxY);
|
||||
}
|
||||
|
||||
Offset clampPosition({Offset position, double scale}) {
|
||||
final _scale = scale ?? this.scale;
|
||||
final _position = position ?? this.position;
|
||||
|
||||
final computedWidth = scaleBoundaries.childSize.width * _scale;
|
||||
final computedHeight = scaleBoundaries.childSize.height * _scale;
|
||||
|
||||
final screenWidth = scaleBoundaries.viewportSize.width;
|
||||
final screenHeight = scaleBoundaries.viewportSize.height;
|
||||
|
||||
var finalX = 0.0;
|
||||
if (screenWidth < computedWidth) {
|
||||
final cornersX = this.cornersX(scale: _scale);
|
||||
finalX = _position.dx.clamp(cornersX.min, cornersX.max);
|
||||
}
|
||||
|
||||
var finalY = 0.0;
|
||||
if (screenHeight < computedHeight) {
|
||||
final cornersY = this.cornersY(scale: _scale);
|
||||
finalY = _position.dy.clamp(cornersY.min, cornersY.max);
|
||||
}
|
||||
|
||||
return Offset(finalX, finalY);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animateScale = null;
|
||||
_streamSubs.forEach((sub) => sub.cancel());
|
||||
_streamSubs.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double getScaleForScaleState(
|
||||
ScaleState scaleState,
|
||||
ScaleBoundaries scaleBoundaries,
|
||||
) {
|
||||
double _clamp(double scale, ScaleBoundaries boundaries) => scale.clamp(boundaries.minScale, boundaries.maxScale);
|
||||
|
||||
switch (scaleState) {
|
||||
case ScaleState.initial:
|
||||
case ScaleState.zoomedIn:
|
||||
case ScaleState.zoomedOut:
|
||||
return _clamp(scaleBoundaries.initialScale, scaleBoundaries);
|
||||
case ScaleState.covering:
|
||||
return _clamp(ScaleLevel.scaleForCovering(scaleBoundaries.viewportSize, scaleBoundaries.childSize), scaleBoundaries);
|
||||
case ScaleState.originalSize:
|
||||
return _clamp(1.0, scaleBoundaries);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple class to store a min and a max value
|
||||
class CornersRange {
|
||||
const CornersRange(this.min, this.max);
|
||||
|
||||
final double min;
|
||||
final double max;
|
||||
}
|
28
lib/widgets/common/magnifier/controller/state.dart
Normal file
28
lib/widgets/common/magnifier/controller/state.dart
Normal file
|
@ -0,0 +1,28 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class MagnifierState {
|
||||
const MagnifierState({
|
||||
@required this.position,
|
||||
@required this.scale,
|
||||
@required this.source,
|
||||
});
|
||||
|
||||
final Offset position;
|
||||
final double scale;
|
||||
final ChangeSource source;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(position, scale, source);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}';
|
||||
}
|
||||
|
||||
enum ChangeSource { internal, gesture, animation }
|
290
lib/widgets/common/magnifier/core/core.dart
Normal file
290
lib/widgets/common/magnifier/core/core.dart
Normal file
|
@ -0,0 +1,290 @@
|
|||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/core/gesture_detector.dart';
|
||||
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Internal widget in which controls all animations lifecycle, core responses
|
||||
/// to user gestures, updates to the controller state and mounts the entire Layout
|
||||
class MagnifierCore extends StatefulWidget {
|
||||
const MagnifierCore({
|
||||
Key key,
|
||||
@required this.child,
|
||||
@required this.onTap,
|
||||
@required this.gestureDetectorBehavior,
|
||||
@required this.controller,
|
||||
@required this.scaleBoundaries,
|
||||
@required this.scaleStateCycle,
|
||||
@required this.scaleStateController,
|
||||
@required this.applyScale,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget child;
|
||||
|
||||
final MagnifierController controller;
|
||||
final MagnifierScaleStateController scaleStateController;
|
||||
final ScaleBoundaries scaleBoundaries;
|
||||
final ScaleStateCycle scaleStateCycle;
|
||||
|
||||
final MagnifierTapCallback onTap;
|
||||
|
||||
final HitTestBehavior gestureDetectorBehavior;
|
||||
final bool applyScale;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return MagnifierCoreState();
|
||||
}
|
||||
}
|
||||
|
||||
class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector {
|
||||
Offset _normalizedPosition;
|
||||
double _scaleBefore;
|
||||
|
||||
AnimationController _scaleAnimationController;
|
||||
Animation<double> _scaleAnimation;
|
||||
|
||||
AnimationController _positionAnimationController;
|
||||
Animation<Offset> _positionAnimation;
|
||||
|
||||
ScaleBoundaries cachedScaleBoundaries;
|
||||
|
||||
void handleScaleAnimation() {
|
||||
setScale(_scaleAnimation.value, ChangeSource.animation);
|
||||
}
|
||||
|
||||
void handlePositionAnimate() {
|
||||
controller.setPosition(_positionAnimation.value, ChangeSource.animation);
|
||||
}
|
||||
|
||||
void onScaleStart(ScaleStartDetails details) {
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
}
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final newScale = _scaleBefore * details.scale;
|
||||
final delta = details.focalPoint - _normalizedPosition;
|
||||
|
||||
updateScaleStateFromNewScale(newScale, ChangeSource.gesture);
|
||||
|
||||
//
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: clampPosition(position: delta * details.scale),
|
||||
source: ChangeSource.gesture,
|
||||
);
|
||||
}
|
||||
|
||||
void onScaleEnd(ScaleEndDetails details) {
|
||||
final _scale = scale;
|
||||
final _position = controller.position;
|
||||
final maxScale = scaleBoundaries.maxScale;
|
||||
final minScale = scaleBoundaries.minScale;
|
||||
|
||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||
if (_scale > maxScale) {
|
||||
final scaleComebackRatio = maxScale / _scale;
|
||||
animateScale(_scale, maxScale);
|
||||
final clampedPosition = clampPosition(
|
||||
position: _position * scaleComebackRatio,
|
||||
scale: maxScale,
|
||||
);
|
||||
animatePosition(_position, clampedPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
//animate back to minScale if gesture fell smaller than the minScale specified
|
||||
if (_scale < minScale) {
|
||||
final scaleComebackRatio = minScale / _scale;
|
||||
animateScale(_scale, minScale);
|
||||
animatePosition(
|
||||
_position,
|
||||
clampPosition(
|
||||
position: _position * scaleComebackRatio,
|
||||
scale: minScale,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// get magnitude from gesture velocity
|
||||
final magnitude = details.velocity.pixelsPerSecond.distance;
|
||||
|
||||
// animate velocity only if there is no scale change and a significant magnitude
|
||||
if (_scaleBefore / _scale == 1.0 && magnitude >= 400.0) {
|
||||
final direction = details.velocity.pixelsPerSecond / magnitude;
|
||||
animatePosition(
|
||||
_position,
|
||||
clampPosition(position: _position + direction * 100.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onTap(TapUpDetails details) {
|
||||
if (widget.onTap == null) return;
|
||||
|
||||
final viewportTapPosition = details.localPosition;
|
||||
final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition);
|
||||
widget.onTap.call(context, details, controller.value, childTapPosition);
|
||||
}
|
||||
|
||||
void onDoubleTap(TapDownDetails details) {
|
||||
final viewportTapPosition = details?.localPosition;
|
||||
final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition);
|
||||
nextScaleState(ChangeSource.gesture, childFocalPoint: childTapPosition);
|
||||
}
|
||||
|
||||
void animateScale(double from, double to) {
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: from,
|
||||
end: to,
|
||||
).animate(_scaleAnimationController);
|
||||
_scaleAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animatePosition(Offset from, Offset to) {
|
||||
_positionAnimation = Tween<Offset>(begin: from, end: to).animate(_positionAnimationController);
|
||||
_positionAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
onAnimationStatusCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scale is equal to initial after scale animation update
|
||||
void onAnimationStatusCompleted() {
|
||||
if (scaleStateController.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) {
|
||||
scaleStateController.setScaleState(ScaleState.initial, ChangeSource.animation);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleAnimationController = AnimationController(vsync: this)..addListener(handleScaleAnimation);
|
||||
_scaleAnimationController.addStatusListener(onAnimationStatus);
|
||||
|
||||
_positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate);
|
||||
|
||||
startListeners();
|
||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
void animateOnScaleStateUpdate(double prevScale, double nextScale, Offset nextPosition) {
|
||||
animateScale(prevScale, nextScale);
|
||||
animatePosition(controller.position, nextPosition);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleAnimationController.removeStatusListener(onAnimationStatus);
|
||||
_scaleAnimationController.dispose();
|
||||
_positionAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if we need a recalc on the scale
|
||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
||||
markNeedsScaleRecalc = true;
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
return StreamBuilder<MagnifierState>(
|
||||
stream: controller.outputStateStream,
|
||||
initialData: controller.prevValue,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final value = snapshot.data;
|
||||
final applyScale = widget.applyScale;
|
||||
|
||||
final computedScale = applyScale ? scale : 1.0;
|
||||
|
||||
final matrix = Matrix4.identity()
|
||||
..translate(value.position.dx, value.position.dy)
|
||||
..scale(computedScale);
|
||||
|
||||
final Widget customChildLayout = CustomSingleChildLayout(
|
||||
delegate: _CenterWithOriginalSizeDelegate(
|
||||
scaleBoundaries.childSize,
|
||||
basePosition,
|
||||
applyScale,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
return MagnifierGestureDetector(
|
||||
child: Transform(
|
||||
child: customChildLayout,
|
||||
transform: matrix,
|
||||
alignment: basePosition,
|
||||
),
|
||||
onDoubleTap: onDoubleTap,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTap == null ? null : onTap,
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
||||
const _CenterWithOriginalSizeDelegate(
|
||||
this.subjectSize,
|
||||
this.basePosition,
|
||||
this.applyScale,
|
||||
);
|
||||
|
||||
final Size subjectSize;
|
||||
final Alignment basePosition;
|
||||
final bool applyScale;
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final childWidth = applyScale ? subjectSize.width : childSize.width;
|
||||
final childHeight = applyScale ? subjectSize.height : childSize.height;
|
||||
|
||||
final halfWidth = (size.width - childWidth) / 2;
|
||||
final halfHeight = (size.height - childHeight) / 2;
|
||||
|
||||
final offsetX = halfWidth * (basePosition.x + 1);
|
||||
final offsetY = halfHeight * (basePosition.y + 1);
|
||||
return Offset(offsetX, offsetY);
|
||||
}
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return applyScale ? BoxConstraints.tight(subjectSize) : BoxConstraints();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(subjectSize, basePosition, applyScale);
|
||||
}
|
231
lib/widgets/common/magnifier/core/gesture_detector.dart
Normal file
231
lib/widgets/common/magnifier/core/gesture_detector.dart
Normal file
|
@ -0,0 +1,231 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../pan/corner_hit_detector.dart';
|
||||
|
||||
class MagnifierGestureDetector extends StatefulWidget {
|
||||
const MagnifierGestureDetector({
|
||||
Key key,
|
||||
this.hitDetector,
|
||||
this.onScaleStart,
|
||||
this.onScaleUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onTapDown,
|
||||
this.onTapUp,
|
||||
this.onDoubleTap,
|
||||
this.behavior,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
final CornerHitDetector hitDetector;
|
||||
final GestureScaleStartCallback onScaleStart;
|
||||
final GestureScaleUpdateCallback onScaleUpdate;
|
||||
final GestureScaleEndCallback onScaleEnd;
|
||||
|
||||
final GestureTapDownCallback onTapDown;
|
||||
final GestureTapUpCallback onTapUp;
|
||||
final GestureTapDownCallback onDoubleTap;
|
||||
|
||||
final HitTestBehavior behavior;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
_MagnifierGestureDetectorState createState() => _MagnifierGestureDetectorState();
|
||||
}
|
||||
|
||||
class _MagnifierGestureDetectorState extends State<MagnifierGestureDetector> {
|
||||
TapDownDetails doubleTapDetails;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = MagnifierGestureDetectorScope.of(context);
|
||||
|
||||
final axis = scope?.axis;
|
||||
final touchSlopFactor = scope?.touchSlopFactor;
|
||||
|
||||
final gestures = <Type, GestureRecognizerFactory>{};
|
||||
|
||||
if (widget.onTapDown != null || widget.onTapUp != null) {
|
||||
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(instance) {
|
||||
instance
|
||||
..onTapDown = widget.onTapDown
|
||||
..onTapUp = widget.onTapUp;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers<MagnifierGestureRecognizer>(
|
||||
() => MagnifierGestureRecognizer(
|
||||
hitDetector: widget.hitDetector,
|
||||
debugOwner: this,
|
||||
validateAxis: axis,
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
),
|
||||
(instance) {
|
||||
instance
|
||||
..onStart = widget.onScaleStart
|
||||
..onUpdate = widget.onScaleUpdate
|
||||
..onEnd = widget.onScaleEnd;
|
||||
},
|
||||
);
|
||||
|
||||
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(instance) {
|
||||
instance.onDoubleTapCancel = () => doubleTapDetails = null;
|
||||
instance.onDoubleTapDown = (details) => doubleTapDetails = details;
|
||||
instance.onDoubleTap = () {
|
||||
widget.onDoubleTap(doubleTapDetails);
|
||||
doubleTapDetails = null;
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return RawGestureDetector(
|
||||
child: widget.child,
|
||||
gestures: gestures,
|
||||
behavior: widget.behavior ?? HitTestBehavior.translucent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
|
||||
MagnifierGestureRecognizer({
|
||||
this.hitDetector,
|
||||
Object debugOwner,
|
||||
this.validateAxis,
|
||||
this.touchSlopFactor = 2,
|
||||
PointerDeviceKind kind,
|
||||
}) : super(debugOwner: debugOwner, kind: kind);
|
||||
final CornerHitDetector hitDetector;
|
||||
final List<Axis> validateAxis;
|
||||
final double touchSlopFactor;
|
||||
|
||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||
|
||||
Offset _initialFocalPoint;
|
||||
Offset _currentFocalPoint;
|
||||
double _initialSpan;
|
||||
double _currentSpan;
|
||||
|
||||
bool ready = true;
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerEvent event) {
|
||||
if (ready) {
|
||||
ready = false;
|
||||
_initialSpan = 0.0;
|
||||
_currentSpan = 0.0;
|
||||
_pointerLocations = <int, Offset>{};
|
||||
}
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
ready = true;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (validateAxis != null && validateAxis.isNotEmpty) {
|
||||
var didChangeConfiguration = false;
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!event.synthesized) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
}
|
||||
} else if (event is PointerDownEvent) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
didChangeConfiguration = true;
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_pointerLocations.remove(event.pointer);
|
||||
didChangeConfiguration = true;
|
||||
}
|
||||
|
||||
_updateDistances();
|
||||
|
||||
if (didChangeConfiguration) {
|
||||
// cf super._reconfigure
|
||||
_initialFocalPoint = _currentFocalPoint;
|
||||
_initialSpan = _currentSpan;
|
||||
}
|
||||
|
||||
_decideIfWeAcceptEvent(event);
|
||||
}
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
void _updateDistances() {
|
||||
// cf super._update
|
||||
final count = _pointerLocations.keys.length;
|
||||
|
||||
// Compute the focal point
|
||||
var focalPoint = Offset.zero;
|
||||
for (final pointer in _pointerLocations.keys) {
|
||||
focalPoint += _pointerLocations[pointer];
|
||||
}
|
||||
_currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero;
|
||||
|
||||
// Span is the average deviation from focal point. Horizontal and vertical
|
||||
// spans are the average deviations from the focal point's horizontal and
|
||||
// vertical coordinates, respectively.
|
||||
var totalDeviation = 0.0;
|
||||
for (final pointer in _pointerLocations.keys) {
|
||||
totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]).distance;
|
||||
}
|
||||
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
|
||||
}
|
||||
|
||||
void _decideIfWeAcceptEvent(PointerEvent event) {
|
||||
if (!(event is PointerMoveEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pointerLocations.keys.length >= 2) {
|
||||
// when there are multiple pointers, we always accept the gesture to scale
|
||||
// as this is not competing with single taps or other drag gestures
|
||||
acceptGesture(event.pointer);
|
||||
return;
|
||||
}
|
||||
|
||||
final move = _initialFocalPoint - _currentFocalPoint;
|
||||
var shouldMove = false;
|
||||
if (validateAxis.length == 2) {
|
||||
// the image is the descendant of gesture detector(s) handling drag in both directions
|
||||
final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move);
|
||||
final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move);
|
||||
if (shouldMoveX == shouldMoveY) {
|
||||
// consistently can/cannot pan the image in both direction the same way
|
||||
shouldMove = shouldMoveX;
|
||||
} else {
|
||||
// can pan the image in one direction, but should yield to an ascendant gesture detector in the other one
|
||||
final d = move.direction;
|
||||
// the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details
|
||||
final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi);
|
||||
final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4);
|
||||
shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY);
|
||||
}
|
||||
} else {
|
||||
// the image is the descendant of a gesture detector handling drag in one direction
|
||||
shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move);
|
||||
}
|
||||
if (shouldMove) {
|
||||
final spanDelta = (_currentSpan - _initialSpan).abs();
|
||||
final focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance;
|
||||
// warning: do not compare `focalPointDelta` to `kPanSlop`
|
||||
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
|
||||
// and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
|
||||
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
|
||||
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
|
||||
if (spanDelta > kScaleSlop || focalPointDelta > kTouchSlop * touchSlopFactor) {
|
||||
acceptGesture(event.pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
169
lib/widgets/common/magnifier/magnifier.dart
Normal file
169
lib/widgets/common/magnifier/magnifier.dart
Normal file
|
@ -0,0 +1,169 @@
|
|||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/core/core.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// `Magnifier` is derived from `photo_view` package v0.9.2:
|
||||
/// - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`)
|
||||
/// - removed rotation and many customization parameters
|
||||
/// - removed ignorable/ignoring partial notifiers
|
||||
/// - formatted, renamed and reorganized
|
||||
/// - fixed gesture recognizers when used inside a scrollable widget like `PageView`
|
||||
/// - fixed corner hit detection when in containers scrollable in both axes
|
||||
/// - fixed corner hit detection issues due to imprecise double comparisons
|
||||
/// - added single & double tap position feedback
|
||||
/// - fixed focusing on tap position when scaling by double tap
|
||||
class Magnifier extends StatefulWidget {
|
||||
const Magnifier({
|
||||
Key key,
|
||||
@required this.child,
|
||||
this.childSize,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.scaleStateCycle,
|
||||
this.onTap,
|
||||
this.gestureDetectorBehavior,
|
||||
this.applyScale,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value.
|
||||
final Size childSize;
|
||||
|
||||
/// Defines the maximum size in which the image will be allowed to assume, it is proportional to the original image size.
|
||||
final ScaleLevel maxScale;
|
||||
|
||||
/// Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size.
|
||||
final ScaleLevel minScale;
|
||||
|
||||
/// Defines the size the image will assume when the component is initialized, it is proportional to the original image size.
|
||||
final ScaleLevel initialScale;
|
||||
|
||||
final MagnifierController controller;
|
||||
final MagnifierScaleStateController scaleStateController;
|
||||
final ScaleStateCycle scaleStateCycle;
|
||||
final MagnifierTapCallback onTap;
|
||||
final HitTestBehavior gestureDetectorBehavior;
|
||||
final bool applyScale;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _MagnifierState();
|
||||
}
|
||||
}
|
||||
|
||||
class _MagnifierState extends State<Magnifier> {
|
||||
Size _childSize;
|
||||
|
||||
bool _controlledController;
|
||||
MagnifierController _controller;
|
||||
|
||||
bool _controlledScaleStateController;
|
||||
MagnifierScaleStateController _scaleStateController;
|
||||
|
||||
void _setChildSize(Size childSize) {
|
||||
_childSize = childSize;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setChildSize(widget.childSize);
|
||||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = MagnifierController();
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = MagnifierScaleStateController();
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Magnifier oldWidget) {
|
||||
if (oldWidget.childSize != widget.childSize && widget.childSize != null) {
|
||||
setState(() {
|
||||
_setChildSize(widget.childSize);
|
||||
});
|
||||
}
|
||||
if (widget.controller == null) {
|
||||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
_controller = MagnifierController();
|
||||
}
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
if (!_controlledScaleStateController) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = MagnifierScaleStateController();
|
||||
}
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController;
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controlledController) {
|
||||
_controller.dispose();
|
||||
}
|
||||
if (_controlledScaleStateController) {
|
||||
_scaleStateController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? ScaleLevel(factor: double.infinity),
|
||||
widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained),
|
||||
constraints.biggest,
|
||||
_childSize ?? constraints.biggest,
|
||||
);
|
||||
|
||||
return MagnifierCore(
|
||||
child: widget.child,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTap: widget.onTap,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
applyScale: widget.applyScale ?? true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef MagnifierTapCallback = Function(
|
||||
BuildContext context,
|
||||
TapUpDetails details,
|
||||
MagnifierState state,
|
||||
Offset childTapPosition,
|
||||
);
|
76
lib/widgets/common/magnifier/pan/corner_hit_detector.dart
Normal file
76
lib/widgets/common/magnifier/pan/corner_hit_detector.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
mixin CornerHitDetector on MagnifierControllerDelegate {
|
||||
_AxisHit hitAxis() => _AxisHit(_hitCornersX(), _hitCornersY());
|
||||
|
||||
// the child width/height is not accurate for some image size & scale combos
|
||||
// e.g. 3580.0 * 0.1005586592178771 yields 360.0
|
||||
// but 4764.0 * 0.07556675062972293 yields 360.00000000000006
|
||||
// so be sure to compare with `precisionErrorTolerance`
|
||||
|
||||
_CornerHit _hitCornersX() {
|
||||
final childWidth = scaleBoundaries.childSize.width * scale;
|
||||
final viewportWidth = scaleBoundaries.viewportSize.width;
|
||||
if (viewportWidth + precisionErrorTolerance >= childWidth) {
|
||||
return _CornerHit(true, true);
|
||||
}
|
||||
final x = -position.dx;
|
||||
final cornersX = this.cornersX();
|
||||
return _CornerHit(x <= cornersX.min, x >= cornersX.max);
|
||||
}
|
||||
|
||||
_CornerHit _hitCornersY() {
|
||||
final childHeight = scaleBoundaries.childSize.height * scale;
|
||||
final viewportHeight = scaleBoundaries.viewportSize.height;
|
||||
if (viewportHeight + precisionErrorTolerance >= childHeight) {
|
||||
return _CornerHit(true, true);
|
||||
}
|
||||
final y = -position.dy;
|
||||
final cornersY = this.cornersY();
|
||||
return _CornerHit(y <= cornersY.min, y >= cornersY.max);
|
||||
}
|
||||
|
||||
bool shouldMoveX(Offset move) {
|
||||
final hitCornersX = _hitCornersX();
|
||||
if (hitCornersX.hasHitAny && move != Offset.zero) {
|
||||
if (hitCornersX.hasHitBoth) return false;
|
||||
if (hitCornersX.hasHitMax) return move.dx < 0;
|
||||
return move.dx > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool shouldMoveY(Offset move) {
|
||||
final hitCornersY = _hitCornersY();
|
||||
if (hitCornersY.hasHitAny && move != Offset.zero) {
|
||||
if (hitCornersY.hasHitBoth) return false;
|
||||
if (hitCornersY.hasHitMax) return move.dy < 0;
|
||||
return move.dy > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _AxisHit {
|
||||
_AxisHit(this.hasHitX, this.hasHitY);
|
||||
|
||||
final _CornerHit hasHitX;
|
||||
final _CornerHit hasHitY;
|
||||
|
||||
bool get hasHitAny => hasHitX.hasHitAny || hasHitY.hasHitAny;
|
||||
|
||||
bool get hasHitBoth => hasHitX.hasHitBoth && hasHitY.hasHitBoth;
|
||||
}
|
||||
|
||||
class _CornerHit {
|
||||
const _CornerHit(this.hasHitMin, this.hasHitMax);
|
||||
|
||||
final bool hasHitMin;
|
||||
final bool hasHitMax;
|
||||
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
}
|
33
lib/widgets/common/magnifier/pan/gesture_detector_scope.dart
Normal file
33
lib/widgets/common/magnifier/pan/gesture_detector_scope.dart
Normal file
|
@ -0,0 +1,33 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// When a `Magnifier` is wrapped in this inherited widget,
|
||||
/// it will check whether the zoomed content has hit edges,
|
||||
/// and if so, will let parent gesture detectors win the gesture arena
|
||||
///
|
||||
/// Useful when placing Magnifier inside a gesture sensitive context,
|
||||
/// such as [PageView], [Dismissible], [BottomSheet].
|
||||
class MagnifierGestureDetectorScope extends InheritedWidget {
|
||||
const MagnifierGestureDetectorScope({
|
||||
this.axis,
|
||||
this.touchSlopFactor = .8,
|
||||
@required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
static MagnifierGestureDetectorScope of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<MagnifierGestureDetectorScope>();
|
||||
return scope;
|
||||
}
|
||||
|
||||
final List<Axis> axis;
|
||||
|
||||
// in [0, 1[
|
||||
// 0: most reactive but will not let tap recognizers accept gestures
|
||||
// <1: less reactive but gives the most leeway to other recognizers
|
||||
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(MagnifierGestureDetectorScope oldWidget) {
|
||||
return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor;
|
||||
}
|
||||
}
|
29
lib/widgets/common/magnifier/pan/scroll_physics.dart
Normal file
29
lib/widgets/common/magnifier/pan/scroll_physics.dart
Normal file
|
@ -0,0 +1,29 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
|
||||
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
|
||||
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
|
||||
// and let other recognizers accept the gesture instead
|
||||
class MagnifierScrollerPhysics extends ScrollPhysics {
|
||||
const MagnifierScrollerPhysics({
|
||||
this.touchSlopFactor = 1,
|
||||
ScrollPhysics parent,
|
||||
}) : super(parent: parent);
|
||||
|
||||
// in [0, 1]
|
||||
// 0: most reactive but will not let Magnifier recognizers accept gestures
|
||||
// 1: less reactive but gives the most leeway to Magnifier recognizers
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
MagnifierScrollerPhysics applyTo(ScrollPhysics ancestor) {
|
||||
return MagnifierScrollerPhysics(
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
|
||||
}
|
60
lib/widgets/common/magnifier/scale/scale_boundaries.dart
Normal file
60
lib/widgets/common/magnifier/scale/scale_boundaries.dart
Normal file
|
@ -0,0 +1,60 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Internal class to wrap custom scale boundaries (min, max and initial)
|
||||
/// Also, stores values regarding the two sizes: the container and the child.
|
||||
class ScaleBoundaries {
|
||||
const ScaleBoundaries(
|
||||
this._minScale,
|
||||
this._maxScale,
|
||||
this._initialScale,
|
||||
this.viewportSize,
|
||||
this.childSize,
|
||||
);
|
||||
|
||||
final ScaleLevel _minScale;
|
||||
final ScaleLevel _maxScale;
|
||||
final ScaleLevel _initialScale;
|
||||
final Size viewportSize;
|
||||
final Size childSize;
|
||||
|
||||
double _scaleForLevel(ScaleLevel level) {
|
||||
final factor = level.factor;
|
||||
switch (level.ref) {
|
||||
case ScaleReference.contained:
|
||||
return factor * ScaleLevel.scaleForContained(viewportSize, childSize);
|
||||
case ScaleReference.covered:
|
||||
return factor * ScaleLevel.scaleForCovering(viewportSize, childSize);
|
||||
case ScaleReference.absolute:
|
||||
default:
|
||||
return factor;
|
||||
}
|
||||
}
|
||||
|
||||
double get minScale => _scaleForLevel(_minScale);
|
||||
|
||||
double get maxScale => _scaleForLevel(_maxScale).clamp(minScale, double.infinity);
|
||||
|
||||
double get initialScale => _scaleForLevel(_initialScale).clamp(minScale, maxScale);
|
||||
|
||||
Offset toChildPosition(MagnifierController controller, Offset viewportPosition) {
|
||||
final position = controller.position;
|
||||
final scale = controller.scale;
|
||||
final viewportCenter = viewportSize.center(Offset.zero);
|
||||
final childCenter = childSize.center(Offset.zero);
|
||||
final childPosition = (viewportPosition - viewportCenter) / scale - position / scale + childCenter;
|
||||
return childPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}';
|
||||
}
|
32
lib/widgets/common/magnifier/scale/scale_level.dart
Normal file
32
lib/widgets/common/magnifier/scale/scale_level.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class ScaleLevel {
|
||||
final ScaleReference ref;
|
||||
final double factor;
|
||||
|
||||
const ScaleLevel({
|
||||
this.ref = ScaleReference.absolute,
|
||||
this.factor = 1.0,
|
||||
});
|
||||
|
||||
static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height);
|
||||
|
||||
static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ScaleLevel && other.ref == ref && other.factor == factor;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(ref, factor);
|
||||
}
|
||||
|
||||
enum ScaleReference { absolute, contained, covered }
|
|
@ -0,0 +1,41 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
|
||||
|
||||
class MagnifierScaleStateController {
|
||||
ScaleStateChange _scaleState;
|
||||
StreamController<ScaleStateChange> _outputScaleStateCtrl;
|
||||
ScaleStateChange prevScaleState;
|
||||
|
||||
Stream<ScaleStateChange> get scaleStateChangeStream => _outputScaleStateCtrl.stream;
|
||||
|
||||
ScaleStateChange get scaleState => _scaleState;
|
||||
|
||||
bool get hasChanged => prevScaleState != scaleState;
|
||||
|
||||
bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut;
|
||||
|
||||
MagnifierScaleStateController() {
|
||||
_scaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal);
|
||||
prevScaleState = _scaleState;
|
||||
|
||||
_outputScaleStateCtrl = StreamController<ScaleStateChange>.broadcast();
|
||||
_outputScaleStateCtrl.sink.add(_scaleState);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_outputScaleStateCtrl.close();
|
||||
}
|
||||
|
||||
void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) {
|
||||
if (_scaleState.state == newValue) return;
|
||||
|
||||
prevScaleState = _scaleState;
|
||||
_scaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint);
|
||||
_outputScaleStateCtrl.sink.add(scaleState);
|
||||
}
|
||||
}
|
53
lib/widgets/common/magnifier/scale/state.dart
Normal file
53
lib/widgets/common/magnifier/scale/state.dart
Normal file
|
@ -0,0 +1,53 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class ScaleStateChange {
|
||||
const ScaleStateChange({
|
||||
@required this.state,
|
||||
@required this.source,
|
||||
this.childFocalPoint,
|
||||
});
|
||||
|
||||
final ScaleState state;
|
||||
final ChangeSource source;
|
||||
final Offset childFocalPoint;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(state, source, childFocalPoint);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}';
|
||||
}
|
||||
|
||||
enum ScaleState {
|
||||
initial,
|
||||
covering,
|
||||
originalSize,
|
||||
zoomedIn,
|
||||
zoomedOut,
|
||||
}
|
||||
|
||||
ScaleState defaultScaleStateCycle(ScaleState actual) {
|
||||
switch (actual) {
|
||||
case ScaleState.initial:
|
||||
return ScaleState.covering;
|
||||
case ScaleState.covering:
|
||||
return ScaleState.originalSize;
|
||||
case ScaleState.originalSize:
|
||||
return ScaleState.initial;
|
||||
case ScaleState.zoomedIn:
|
||||
case ScaleState.zoomedOut:
|
||||
return ScaleState.initial;
|
||||
default:
|
||||
return ScaleState.initial;
|
||||
}
|
||||
}
|
||||
|
||||
typedef ScaleStateCycle = ScaleState Function(ScaleState actual);
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
|
@ -22,7 +23,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:screen/screen.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
key: Key('vertical-pageview'),
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: widget.verticalPager,
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
|
||||
physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
|
||||
onPageChanged: (page) {
|
||||
widget.onVerticalPageChanged(page);
|
||||
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class MultiImagePage extends StatefulWidget {
|
||||
|
@ -34,13 +35,13 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return PhotoViewGestureDetectorScope(
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.horizontal, Axis.vertical],
|
||||
child: PageView.builder(
|
||||
key: Key('horizontal-pageview'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.pageController,
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
|
@ -49,7 +50,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onTap: widget.onTap,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
),
|
||||
|
@ -84,11 +85,11 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return PhotoViewGestureDetectorScope(
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.vertical],
|
||||
child: ImageView(
|
||||
entry: widget.entry,
|
||||
onTap: widget.onTap,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,26 +1,30 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:aves/widgets/fullscreen/tiled_view.dart';
|
||||
import 'package:aves/widgets/fullscreen/video_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ImageView extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final Object heroTag;
|
||||
final VoidCallback onTap;
|
||||
final MagnifierTapCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final VoidCallback onDisposed;
|
||||
|
||||
|
@ -38,26 +42,25 @@ class ImageView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ImageViewState extends State<ImageView> {
|
||||
final PhotoViewController _photoViewController = PhotoViewController();
|
||||
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
|
||||
final MagnifierController _magnifierController = MagnifierController();
|
||||
final MagnifierScaleStateController _magnifierScaleStateController = MagnifierScaleStateController();
|
||||
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
|
||||
StreamSubscription<PhotoViewControllerValue> _subscription;
|
||||
Size _photoViewChildSize;
|
||||
StreamSubscription<MagnifierState> _subscription;
|
||||
Size _magnifierChildSize;
|
||||
|
||||
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
|
||||
static const maxScale = 2.0;
|
||||
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
static const maxScale = ScaleLevel(factor: 2.0);
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
VoidCallback get onTap => widget.onTap;
|
||||
MagnifierTapCallback get onTap => widget.onTap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
|
||||
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
|
||||
_photoViewChildSize = entry.displaySize;
|
||||
}
|
||||
_subscription = _magnifierController.outputStateStream.listen(_onViewChanged);
|
||||
_magnifierChildSize = entry.displaySize;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -78,19 +81,9 @@ class _ImageViewState extends State<ImageView> {
|
|||
} else if (entry.isSvg) {
|
||||
child = _buildSvgView();
|
||||
} else if (entry.canDecode) {
|
||||
if (useTile) {
|
||||
child = _buildTiledImageView();
|
||||
} else {
|
||||
child = _buildImageView();
|
||||
child = _buildRasterView();
|
||||
}
|
||||
}
|
||||
child ??= _buildError();
|
||||
|
||||
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
|
||||
// the route transition becomes visible if the final image is loaded before the hero animation is done.
|
||||
|
||||
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
|
||||
// there's a black frame between the hero animation and the final image, even when it's cached.
|
||||
child ??= ErrorChild(onTap: () => onTap?.call(null));
|
||||
|
||||
// no hero for videos, as a typical video first frame is different from its thumbnail
|
||||
return widget.heroTag != null && !entry.isVideo
|
||||
|
@ -102,17 +95,12 @@ class _ImageViewState extends State<ImageView> {
|
|||
: child;
|
||||
}
|
||||
|
||||
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
|
||||
// so the reported offset and scale does not match expected values derived from the original dimensions
|
||||
// besides, large images should be tiled to be memory-friendly
|
||||
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
|
||||
|
||||
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
|
||||
|
||||
// this loading builder shows a transition image until the final image is ready
|
||||
// if the image is already in the cache it will show the final image, otherwise the thumbnail
|
||||
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
|
||||
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
|
||||
// is laid the same way as the final image when `contained`
|
||||
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
|
@ -126,53 +114,20 @@ class _ImageViewState extends State<ImageView> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildImageView() {
|
||||
final uriImage = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
return PhotoView(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
imageProvider: uriImage,
|
||||
// when the full image is ready, we use it in the `loadingBuilder`
|
||||
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
|
||||
loadingBuilder: (context, event) => _loadingBuilder(
|
||||
context,
|
||||
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
|
||||
),
|
||||
loadFailedChild: _buildError(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
imageSizedCallback: (size) {
|
||||
// do not directly update the `ViewState` notifier as this callback is called during build
|
||||
_photoViewChildSize = size;
|
||||
},
|
||||
controller: _photoViewController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
filterQuality: FilterQuality.low,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTiledImageView() {
|
||||
return PhotoView.customChild(
|
||||
Widget _buildRasterView() {
|
||||
return Magnifier(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
child: Selector<MediaQueryData, Size>(
|
||||
selector: (context, mq) => mq.size,
|
||||
builder: (context, mqSize, child) {
|
||||
// When the scale state is cycled to be in its `initial` state (i.e. `contained`), and the device is rotated,
|
||||
// `PhotoView` keeps the scale state as `contained`, but the controller does not update or notify the new scale value.
|
||||
// We cannot use `scaleStateChangedCallback` as a workaround, because the scale state is updated before animating the scale change,
|
||||
// `Magnifier` keeps the scale state as `contained`, but the controller does not update or notify the new scale value.
|
||||
// We cannot monitor scale state changes as a workaround, because the scale state is updated before animating the scale,
|
||||
// so we keep receiving scale updates after the scale state update.
|
||||
// Instead we check the scale state here when the constraints change, so we can reset the obsolete scale value.
|
||||
if (_photoViewScaleStateController.scaleState == PhotoViewScaleState.initial) {
|
||||
final value = PhotoViewControllerValue(position: Offset.zero, scale: 0, rotation: 0, rotationFocusPoint: null);
|
||||
if (_magnifierScaleStateController.scaleState.state == ScaleState.initial) {
|
||||
final value = MagnifierState(position: Offset.zero, scale: 0, source: ChangeSource.internal);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value));
|
||||
}
|
||||
return TiledImageView(
|
||||
|
@ -180,25 +135,24 @@ class _ImageViewState extends State<ImageView> {
|
|||
viewportSize: mqSize,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
baseChild: _loadingBuilder(context, fastThumbnailProvider),
|
||||
errorBuilder: (context, error, stackTrace) => _buildError(),
|
||||
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
|
||||
);
|
||||
},
|
||||
),
|
||||
childSize: entry.displaySize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
scaleStateController: _photoViewScaleStateController,
|
||||
controller: _magnifierController,
|
||||
scaleStateController: _magnifierScaleStateController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
filterQuality: FilterQuality.low,
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
applyScale: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSvgView() {
|
||||
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
|
||||
return PhotoView.customChild(
|
||||
return Magnifier(
|
||||
child: SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
|
@ -206,17 +160,16 @@ class _ImageViewState extends State<ImageView> {
|
|||
colorFilter: colorFilter,
|
||||
),
|
||||
),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
controller: _magnifierController,
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoView() {
|
||||
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
return PhotoView.customChild(
|
||||
return Magnifier(
|
||||
child: videoController != null
|
||||
? AvesVideo(
|
||||
entry: entry,
|
||||
|
@ -224,31 +177,16 @@ class _ImageViewState extends State<ImageView> {
|
|||
)
|
||||
: SizedBox(),
|
||||
childSize: entry.displaySize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
controller: _magnifierController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError() => GestureDetector(
|
||||
onTap: () => onTap?.call(),
|
||||
// use a `Container` with a dummy color to make it expand
|
||||
// so that we can also detect taps around the title `Text`
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: EmptyContent(
|
||||
icon: AIcons.error,
|
||||
text: 'Oops!',
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void _onViewChanged(PhotoViewControllerValue v) {
|
||||
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
|
||||
void _onViewChanged(MagnifierState v) {
|
||||
final viewState = ViewState(v.position, v.scale, _magnifierChildSize);
|
||||
_viewStateNotifier.value = viewState;
|
||||
ViewStateNotification(entry.uri, viewState).dispatch(context);
|
||||
}
|
||||
|
@ -264,9 +202,7 @@ class ViewState {
|
|||
const ViewState(this.position, this.scale, this.size);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
|
||||
}
|
||||
|
||||
class ViewStateNotification extends Notification {
|
||||
|
@ -276,7 +212,30 @@ class ViewStateNotification extends Notification {
|
|||
const ViewStateNotification(this.uri, this.viewState);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
|
||||
}
|
||||
|
||||
class ErrorChild extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ErrorChild({@required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onTap?.call(),
|
||||
// use a `Container` with a dummy color to make it expand
|
||||
// so that we can also detect taps around the title `Text`
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: EmptyContent(
|
||||
icon: AIcons.error,
|
||||
text: 'Oops!',
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef MagnifierTapCallback = void Function(Offset childPosition);
|
||||
|
|
|
@ -6,7 +6,6 @@ import 'package:aves/services/image_file_service.dart';
|
|||
import 'package:aves/utils/string_utils.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
|
|
@ -95,9 +95,7 @@ class XmpNamespace {
|
|||
int get hashCode => namespace.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
||||
}
|
||||
|
||||
class XmpProp {
|
||||
|
@ -116,9 +114,7 @@ class XmpProp {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||
}
|
||||
|
||||
class OpenEmbeddedDataNotification extends Notification {
|
||||
|
@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification {
|
|||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
||||
|
|
|
@ -21,7 +21,8 @@ class Minimap extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
return IgnorePointer(
|
||||
child: Selector<MediaQueryData, Size>(
|
||||
selector: (context, mq) => mq.size,
|
||||
builder: (context, mqSize, child) {
|
||||
return AnimatedBuilder(
|
||||
|
@ -39,7 +40,8 @@ class Minimap extends StatelessWidget {
|
|||
size: size,
|
||||
);
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/image_providers/region_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/image_providers/region_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -37,6 +38,16 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
|
||||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||||
|
||||
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
|
||||
|
||||
ImageProvider get fullImage => UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
|
||||
// magic number used to derive sample size from scale
|
||||
static const scaleFactor = 2.0;
|
||||
|
||||
|
@ -85,10 +96,45 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
final viewState = viewStateNotifier.value;
|
||||
var scale = viewState.scale;
|
||||
if (scale == 0.0) {
|
||||
// for initial scale as `PhotoViewComputedScale.contained`
|
||||
// for initial scale as `contained`
|
||||
scale = _initialScale;
|
||||
}
|
||||
final scaledSize = entry.displaySize * scale;
|
||||
final loading = SizedBox(
|
||||
width: scaledSize.width,
|
||||
height: scaledSize.height,
|
||||
child: widget.baseChild,
|
||||
);
|
||||
|
||||
List<Widget> children;
|
||||
if (useTiles) {
|
||||
children = [
|
||||
loading,
|
||||
..._getTiles(viewState, displayWidth, displayHeight, scale),
|
||||
];
|
||||
} else {
|
||||
children = [
|
||||
if (!imageCache.statusForKey(fullImage).keepAlive) loading,
|
||||
Image(
|
||||
image: fullImage,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
width: scaledSize.width,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: children,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<RegionTile> _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) {
|
||||
final centerOffset = viewState.position;
|
||||
final viewOrigin = Offset(
|
||||
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
|
||||
|
@ -140,19 +186,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: displayWidth * scale,
|
||||
height: displayHeight * scale,
|
||||
child: widget.baseChild,
|
||||
),
|
||||
...tiles,
|
||||
],
|
||||
);
|
||||
});
|
||||
return tiles;
|
||||
}
|
||||
|
||||
int _sampleSizeForScale(double scale) {
|
||||
|
|
|
@ -295,7 +295,5 @@ class EntryByMimeDatum {
|
|||
Color get color => stringToColor(displayText);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}';
|
||||
}
|
||||
|
|
|
@ -704,15 +704,6 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
|
||||
url: "git://github.com/deckerst/photo_view.git"
|
||||
source: git
|
||||
version: "0.9.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -70,10 +70,6 @@ dependencies:
|
|||
pedantic:
|
||||
percent_indicator:
|
||||
permission_handler:
|
||||
photo_view:
|
||||
# path: ../photo_view
|
||||
git:
|
||||
url: git://github.com/deckerst/photo_view.git
|
||||
printing:
|
||||
provider:
|
||||
screen:
|
||||
|
|
Loading…
Reference in a new issue