svg: optional checkered background

This commit is contained in:
Thibault Deckers 2020-12-21 20:11:14 +09:00
parent c9fb94f326
commit b14558e451
17 changed files with 443 additions and 368 deletions

View file

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
enum EntryBackground { black, white, transparent, checkered }
extension ExtraEntryBackground on EntryBackground {
bool get isColor {
switch (this) {
case EntryBackground.black:
case EntryBackground.white:
return true;
default:
return false;
}
}
Color get color {
switch (this) {
case EntryBackground.black:
return Colors.black;
case EntryBackground.white:
return Colors.white;
default:
return null;
}
}
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
@ -54,7 +55,7 @@ class Settings extends ChangeNotifier {
static const coordinateFormatKey = 'coordinates_format';
// rendering
static const svgBackgroundKey = 'svg_background';
static const vectorBackgroundKey = 'vector_background';
// search
static const saveSearchHistoryKey = 'save_search_history';
@ -184,9 +185,9 @@ class Settings extends ChangeNotifier {
// rendering
int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF;
EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values);
set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue);
set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString());
// search

View file

@ -43,7 +43,6 @@ class DecoratedThumbnail extends StatelessWidget {
);
child = Stack(
fit: StackFit.passthrough,
children: [
child,
Positioned(

View file

@ -1,6 +1,10 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'dart:math';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
@ -19,23 +23,39 @@ class ThumbnailVectorImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final child = Container(
// center `SvgPicture` inside `Container` with the thumbnail dimensions
// so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons
width: extent,
height: extent,
child: Selector<Settings, int>(
selector: (context, s) => s.svgBackground,
builder: (context, svgBackground, child) {
final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver);
return SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
colorFilter: colorFilter,
),
width: extent,
height: extent,
final pictureProvider = UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
);
final child = Center(
child: Selector<Settings, EntryBackground>(
selector: (context, s) => s.vectorBackground,
builder: (context, background, child) {
if (background == EntryBackground.transparent) {
return SvgPicture(
pictureProvider,
width: extent,
height: extent,
);
}
final longestSide = max(entry.width, entry.height);
final picture = SvgPicture(
pictureProvider,
width: extent * (entry.width / longestSide),
height: extent * (entry.height / longestSide),
);
Decoration decoration;
if (background == EntryBackground.checkered) {
decoration = CheckeredDecoration(checkSize: extent / 8);
} else if (background.isColor) {
decoration = BoxDecoration(color: background.color);
}
return DecoratedBox(
decoration: decoration,
child: picture,
);
},
),

View file

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
class CheckeredDecoration extends Decoration {
final Color light, dark;
final double checkSize;
final Offset offset;
const CheckeredDecoration({
this.light = const Color(0xFF999999),
this.dark = const Color(0xFF666666),
this.checkSize = 20,
this.offset = Offset.zero,
});
@override
_CheckeredDecorationPainter createBoxPainter([VoidCallback onChanged]) {
return _CheckeredDecorationPainter(this, onChanged);
}
}
class _CheckeredDecorationPainter extends BoxPainter {
final CheckeredDecoration decoration;
const _CheckeredDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final size = configuration.size;
var dx = offset.dx;
var dy = offset.dy;
final lightPaint = Paint()..color = decoration.light;
final darkPaint = Paint()..color = decoration.dark;
final checkSize = decoration.checkSize;
// save/restore because of the clip
canvas.save();
canvas.clipRect(Rect.fromLTWH(dx, dy, size.width, size.height));
canvas.drawPaint(lightPaint);
dx += decoration.offset.dx % (decoration.checkSize * 2);
dy += decoration.offset.dy % (decoration.checkSize * 2);
final xMax = size.width / checkSize;
final yMax = size.height / checkSize;
for (var x = -2; x < xMax; x++) {
for (var y = -2; y < yMax; y++) {
if ((x + y) % 2 == 0) {
final rect = Rect.fromLTWH(dx + x * checkSize, dy + y * checkSize, checkSize, checkSize);
canvas.drawRect(rect, darkPaint);
}
}
}
canvas.restore();
}
}

View file

@ -6,15 +6,15 @@ class HighlightDecoration extends Decoration {
const HighlightDecoration({@required this.color});
@override
HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
return HighlightDecorationPainter(this, onChanged);
_HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
return _HighlightDecorationPainter(this, onChanged);
}
}
class HighlightDecorationPainter extends BoxPainter {
class _HighlightDecorationPainter extends BoxPainter {
final HighlightDecoration decoration;
const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {

View file

@ -2,101 +2,127 @@ import 'dart:async';
import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/state.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/state.dart';
import 'package:flutter/widgets.dart';
class MagnifierController {
final StreamController<MagnifierState> _stateStreamController = StreamController.broadcast();
final StreamController<ScaleBoundaries> _scaleBoundariesStreamController = StreamController.broadcast();
final StreamController<ScaleStateChange> _scaleStateChangeStreamController = StreamController.broadcast();
MagnifierState _currentState, initial, previousState;
ScaleBoundaries _scaleBoundaries;
ScaleStateChange _currentScaleState, previousScaleState;
MagnifierController({
Offset initialPosition = Offset.zero,
}) : _valueNotifier = ValueNotifier(
MagnifierState(
position: initialPosition,
scale: null,
source: ChangeSource.internal,
),
),
super() {
initial = value;
prevValue = initial;
}) : super() {
initial = MagnifierState(
position: initialPosition,
scale: null,
source: ChangeSource.internal,
);
previousState = initial;
_setState(initial);
_valueNotifier.addListener(_changeListener);
_outputCtrl = StreamController<MagnifierState>.broadcast();
_outputCtrl.sink.add(initial);
final _initialScaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal);
previousScaleState = _initialScaleState;
_setScaleState(_initialScaleState);
}
final ValueNotifier<MagnifierState> _valueNotifier;
Stream<MagnifierState> get stateStream => _stateStreamController.stream;
MagnifierState initial;
Stream<ScaleBoundaries> get scaleBoundariesStream => _scaleBoundariesStreamController.stream;
StreamController<MagnifierState> _outputCtrl;
Stream<ScaleStateChange> get scaleStateChangeStream => _scaleStateChangeStreamController.stream;
/// The output for state/value updates. Usually a broadcast [Stream]
Stream<MagnifierState> get outputStateStream => _outputCtrl.stream;
MagnifierState get currentState => _currentState;
/// The state value before the last change or the initial state if the state has not been changed.
MagnifierState prevValue;
Offset get position => currentState.position;
/// Resets the state to the initial value;
void reset() {
_setValue(initial);
}
double get scale => currentState.scale;
void _changeListener() {
_outputCtrl.sink.add(value);
}
ScaleBoundaries get scaleBoundaries => _scaleBoundaries;
ScaleStateChange get scaleState => _currentScaleState;
bool get hasScaleSateChanged => previousScaleState != scaleState;
bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut;
/// Closes streams and removes eventual listeners.
void dispose() {
_outputCtrl.close();
_valueNotifier.dispose();
_stateStreamController.close();
_scaleBoundariesStreamController.close();
_scaleStateChangeStreamController.close();
}
void setPosition(Offset position, ChangeSource 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) {
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({
void update({
Offset position,
double scale,
@required ChangeSource source,
}) {
prevValue = value;
_setValue(MagnifierState(
position: position ?? value.position,
scale: scale ?? value.scale,
position = position ?? this.position;
scale = scale ?? this.scale;
if (this.position == position && this.scale == scale) return;
previousState = currentState;
_setState(MagnifierState(
position: position,
scale: scale,
source: source,
));
}
/// The actual state value
MagnifierState get value => _valueNotifier.value;
void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) {
if (_currentScaleState.state == newValue) return;
void _setValue(MagnifierState newValue) {
if (_valueNotifier.value == newValue) return;
_valueNotifier.value = newValue;
previousScaleState = _currentScaleState;
_currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint);
_scaleStateChangeStreamController.sink.add(scaleState);
}
void _setState(MagnifierState state) {
if (_currentState == state) return;
_currentState = state;
_stateStreamController.sink.add(state);
}
void setScaleBoundaries(ScaleBoundaries scaleBoundaries) {
if (_scaleBoundaries == scaleBoundaries) return;
_scaleBoundaries = scaleBoundaries;
_scaleBoundariesStreamController.sink.add(scaleBoundaries);
if (!isZooming) {
update(
scale: getScaleForScaleState(_currentScaleState.state),
source: ChangeSource.internal,
);
}
}
void _setScaleState(ScaleStateChange scaleState) {
if (_currentScaleState == scaleState) return;
_currentScaleState = scaleState;
_scaleStateChangeStreamController.sink.add(_currentScaleState);
}
double getScaleForScaleState(ScaleState scaleState) {
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;
}
}
}

View file

@ -5,8 +5,6 @@ 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';
@ -16,9 +14,7 @@ import 'package:flutter/widgets.dart';
mixin MagnifierControllerDelegate on State<MagnifierCore> {
MagnifierController get controller => widget.controller;
MagnifierScaleStateController get scaleStateController => widget.scaleStateController;
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
ScaleBoundaries get scaleBoundaries => controller.scaleBoundaries;
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
@ -29,24 +25,24 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
/// Mark if scale need recalculation, useful for scale boundaries changes.
bool markNeedsScaleRecalc = true;
final List<StreamSubscription> _streamSubs = [];
final List<StreamSubscription> _subscriptions = [];
void startListeners() {
_streamSubs.add(controller.outputStateStream.listen(_onMagnifierStateChange));
_streamSubs.add(scaleStateController.scaleStateChangeStream.listen(_onScaleStateChange));
_subscriptions.add(controller.stateStream.listen(_onMagnifierStateChange));
_subscriptions.add(controller.scaleStateChangeStream.listen(_onScaleStateChange));
}
void _onScaleStateChange(ScaleStateChange scaleStateChange) {
if (scaleStateChange.source == ChangeSource.internal) return;
if (!scaleStateController.hasChanged) return;
if (!controller.hasScaleSateChanged) return;
if (_animateScale == null || scaleStateController.isZooming) {
controller.setScale(scale, scaleStateChange.source);
if (_animateScale == null || controller.isZooming) {
controller.update(scale: scale, source: scaleStateChange.source);
return;
}
final nextScaleState = scaleStateChange.state;
final nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
final nextScale = controller.getScaleForScaleState(nextScaleState);
var nextPosition = Offset.zero;
if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) {
final childFocalPoint = scaleStateChange.childFocalPoint;
@ -55,31 +51,31 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
}
}
final prevScale = controller.scale ?? getScaleForScaleState(scaleStateController.prevScaleState.state, scaleBoundaries);
final prevScale = controller.scale ?? controller.getScaleForScaleState(controller.previousScaleState.state);
_animateScale(prevScale, nextScale, nextPosition);
}
void addAnimateOnScaleStateUpdate(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) {
void setScaleStateUpdateAnimation(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;
controller.update(position: clampPosition(), source: state.source);
if (controller.scale == controller.previousState.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);
controller.setScaleState(newScaleState, state.source);
}
Offset get position => controller.position;
double get scale {
final scaleState = scaleStateController.scaleState.state;
final scaleState = controller.scaleState.state;
final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut);
final scaleExistsOnController = controller.scale != null;
if (needsRecalc || !scaleExistsOnController) {
final newScale = getScaleForScaleState(scaleState, scaleBoundaries);
final newScale = controller.getScaleForScaleState(scaleState);
markNeedsScaleRecalc = false;
setScale(newScale, ChangeSource.internal);
return newScale;
@ -87,14 +83,14 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
return controller.scale;
}
void setScale(double scale, ChangeSource source) => controller.setScale(scale, source);
void setScale(double scale, ChangeSource source) => controller.update(scale: scale, source: source);
void updateMultiple({
Offset position,
double scale,
@required Offset position,
@required double scale,
@required ChangeSource source,
}) {
controller.updateMultiple(position: position, scale: scale, source: source);
controller.update(position: position, scale: scale, source: source);
}
void updateScaleStateFromNewScale(double newScale, ChangeSource source) {
@ -102,19 +98,16 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
if (scale != scaleBoundaries.initialScale) {
newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut;
}
scaleStateController.setScaleState(newScaleState, source);
controller.setScaleState(newScaleState, source);
}
void nextScaleState(ChangeSource source, {Offset childFocalPoint}) {
final scaleState = scaleStateController.scaleState.state;
final scaleState = controller.scaleState.state;
if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) {
scaleStateController.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint);
controller.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint);
return;
}
final originalScale = getScaleForScaleState(
scaleState,
scaleBoundaries,
);
final originalScale = controller.getScaleForScaleState(scaleState);
var prevScale = originalScale;
var prevScaleState = scaleState;
@ -125,11 +118,11 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
prevScale = nextScale;
prevScaleState = nextScaleState;
nextScaleState = scaleStateCycle(prevScaleState);
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
nextScale = controller.getScaleForScaleState(nextScaleState);
} while (prevScale == nextScale && scaleState != nextScaleState);
if (originalScale == nextScale) return;
scaleStateController.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint);
controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint);
}
CornersRange cornersX({double scale}) {
@ -188,30 +181,10 @@ mixin MagnifierControllerDelegate on State<MagnifierCore> {
@override
void dispose() {
_animateScale = null;
_streamSubs.forEach((sub) => sub.cancel());
_streamSubs.clear();
_subscriptions.forEach((sub) => sub.cancel());
_subscriptions.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

View file

@ -5,7 +5,6 @@ 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';
@ -18,17 +17,13 @@ class MagnifierCore extends StatefulWidget {
@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;
@ -59,7 +54,7 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
}
void handlePositionAnimate() {
controller.setPosition(_positionAnimation.value, ChangeSource.animation);
controller.update(position: _positionAnimation.value, source: ChangeSource.animation);
}
void onScaleStart(ScaleStartDetails details) {
@ -135,7 +130,7 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
final viewportTapPosition = details.localPosition;
final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition);
widget.onTap.call(context, details, controller.value, childTapPosition);
widget.onTap.call(context, details, controller.currentState, childTapPosition);
}
void onDoubleTap(TapDownDetails details) {
@ -169,8 +164,8 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
/// 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);
if (controller.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) {
controller.setScaleState(ScaleState.initial, ChangeSource.animation);
}
}
@ -183,9 +178,9 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
_positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate);
startListeners();
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
setScaleStateUpdateAnimation(animateOnScaleStateUpdate);
cachedScaleBoundaries = widget.scaleBoundaries;
cachedScaleBoundaries = widget.controller.scaleBoundaries;
}
void animateOnScaleStateUpdate(double prevScale, double nextScale, Offset nextPosition) {
@ -204,49 +199,47 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
@override
Widget build(BuildContext context) {
// Check if we need a recalc on the scale
if (widget.scaleBoundaries != cachedScaleBoundaries) {
if (widget.controller.scaleBoundaries != cachedScaleBoundaries) {
markNeedsScaleRecalc = true;
cachedScaleBoundaries = widget.scaleBoundaries;
cachedScaleBoundaries = widget.controller.scaleBoundaries;
}
return StreamBuilder<MagnifierState>(
stream: controller.outputStateStream,
initialData: controller.prevValue,
stream: controller.stateStream,
initialData: controller.previousState,
builder: (context, snapshot) {
if (snapshot.hasData) {
final value = snapshot.data;
final applyScale = widget.applyScale;
if (!snapshot.hasData) return Container();
final computedScale = applyScale ? scale : 1.0;
final magnifierState = snapshot.data;
final position = magnifierState.position;
final applyScale = widget.applyScale;
final matrix = Matrix4.identity()
..translate(value.position.dx, value.position.dy)
..scale(computedScale);
Widget child = CustomSingleChildLayout(
delegate: _CenterWithOriginalSizeDelegate(
scaleBoundaries.childSize,
basePosition,
applyScale,
),
child: widget.child,
);
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();
}
child = Transform(
transform: Matrix4.identity()
..translate(position.dx, position.dy)
..scale(applyScale ? scale : 1.0),
alignment: basePosition,
child: child,
);
return MagnifierGestureDetector(
child: child,
onDoubleTap: onDoubleTap,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
hitDetector: this,
onTapUp: widget.onTap == null ? null : onTap,
);
});
}
}

View file

@ -3,7 +3,6 @@ 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';
@ -16,14 +15,13 @@ import 'package:flutter/material.dart';
/// - 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
/// - fixed focus when scaling by double-tap/pinch
class Magnifier extends StatefulWidget {
const Magnifier({
Key key,
@required this.child,
this.childSize,
this.controller,
this.scaleStateController,
this.maxScale,
this.minScale,
this.initialScale,
@ -48,7 +46,6 @@ class Magnifier extends StatefulWidget {
final ScaleLevel initialScale;
final MagnifierController controller;
final MagnifierScaleStateController scaleStateController;
final ScaleStateCycle scaleStateCycle;
final MagnifierTapCallback onTap;
final HitTestBehavior gestureDetectorBehavior;
@ -66,9 +63,6 @@ class _MagnifierState extends State<Magnifier> {
bool _controlledController;
MagnifierController _controller;
bool _controlledScaleStateController;
MagnifierScaleStateController _scaleStateController;
void _setChildSize(Size childSize) {
_childSize = childSize.isEmpty ? null : childSize;
}
@ -84,14 +78,6 @@ class _MagnifierState extends State<Magnifier> {
_controlledController = false;
_controller = widget.controller;
}
if (widget.scaleStateController == null) {
_controlledScaleStateController = true;
_scaleStateController = MagnifierScaleStateController();
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController;
}
}
@override
@ -110,16 +96,6 @@ class _MagnifierState extends State<Magnifier> {
_controlledController = false;
_controller = widget.controller;
}
if (widget.scaleStateController == null) {
if (!_controlledScaleStateController) {
_controlledScaleStateController = true;
_scaleStateController = MagnifierScaleStateController();
}
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController;
}
super.didUpdateWidget(oldWidget);
}
@ -128,9 +104,6 @@ class _MagnifierState extends State<Magnifier> {
if (_controlledController) {
_controller.dispose();
}
if (_controlledScaleStateController) {
_scaleStateController.dispose();
}
super.dispose();
}
@ -138,20 +111,18 @@ class _MagnifierState extends State<Magnifier> {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleBoundaries = ScaleBoundaries(
_controller.setScaleBoundaries(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,

View file

@ -1,41 +0,0 @@
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);
}
}

View file

@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
),
ConstrainedBox(
constraints: BoxConstraints(minWidth: 16),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => AnimatedSwitcher(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, child) => AnimatedSwitcher(
duration: Durations.appBarActionChangeAnimation,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
child: child,
),
),
child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(),
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
),
),
)

View file

@ -1,16 +1,19 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.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/fx/checkered_decoration.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_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:aves/widgets/fullscreen/tiled_view.dart';
import 'package:aves/widgets/fullscreen/video_view.dart';
@ -18,7 +21,6 @@ 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:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ImageView extends StatefulWidget {
@ -43,10 +45,8 @@ class ImageView extends StatefulWidget {
class _ImageViewState extends State<ImageView> {
final MagnifierController _magnifierController = MagnifierController();
final MagnifierScaleStateController _magnifierScaleStateController = MagnifierScaleStateController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
StreamSubscription<MagnifierState> _subscription;
Size _magnifierChildSize;
final List<StreamSubscription> _subscriptions = [];
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const minScale = ScaleLevel(ref: ScaleReference.contained);
@ -56,17 +56,20 @@ class _ImageViewState extends State<ImageView> {
MagnifierTapCallback get onTap => widget.onTap;
static const decorationCheckSize = 20.0;
@override
void initState() {
super.initState();
_subscription = _magnifierController.outputStateStream.listen(_onViewChanged);
_magnifierChildSize = entry.displaySize;
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
}
@override
void dispose() {
_subscription.cancel();
_subscription = null;
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
widget.onDisposed?.call();
super.dispose();
}
@ -118,30 +121,14 @@ class _ImageViewState extends State<ImageView> {
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,
// `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 (_magnifierScaleStateController.scaleState.state == ScaleState.initial) {
final value = MagnifierState(position: Offset.zero, scale: 0, source: ChangeSource.internal);
WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value));
}
return TiledImageView(
entry: entry,
viewportSize: mqSize,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
);
},
child: TiledImageView(
entry: entry,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
),
childSize: entry.displaySize,
controller: _magnifierController,
scaleStateController: _magnifierScaleStateController,
maxScale: maxScale,
minScale: minScale,
initialScale: initialScale,
@ -151,8 +138,10 @@ class _ImageViewState extends State<ImageView> {
}
Widget _buildSvgView() {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
return Magnifier(
final background = settings.vectorBackground;
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
Widget child = Magnifier(
child: SvgPicture(
UriPicture(
uri: entry.uri,
@ -167,6 +156,42 @@ class _ImageViewState extends State<ImageView> {
scaleStateCycle: _vectorScaleStateCycle,
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
);
if (background == EntryBackground.checkered) {
child = ValueListenableBuilder<ViewState>(
valueListenable: _viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return child;
final side = viewportSize.shortestSide;
final checkSize = side / ((side / decorationCheckSize).round());
final viewSize = entry.displaySize * viewState.scale;
final decorationSize = Size(min(viewSize.width, viewportSize.width), min(viewSize.height, viewportSize.height));
final offset = Offset(decorationSize.width - viewportSize.width, decorationSize.height - viewportSize.height) / 2;
return Stack(
alignment: Alignment.center,
children: [
Positioned(
width: decorationSize.width,
height: decorationSize.height,
child: DecoratedBox(
decoration: CheckeredDecoration(
checkSize: checkSize,
offset: offset,
),
),
),
child,
],
);
},
child: child,
);
}
return child;
}
Widget _buildVideoView() {
@ -187,8 +212,16 @@ class _ImageViewState extends State<ImageView> {
);
}
void _onViewChanged(MagnifierState v) {
final viewState = ViewState(v.position, v.scale, _magnifierChildSize);
void _onViewStateChanged(MagnifierState v) {
final current = _viewStateNotifier.value;
final viewState = ViewState(v.position, v.scale, current.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
final current = _viewStateNotifier.value;
final viewState = ViewState(current.position, current.scale, v.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
@ -206,14 +239,14 @@ class _ImageViewState extends State<ImageView> {
class ViewState {
final Offset position;
final double scale;
final Size size;
final Size viewportSize;
static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null);
static const ViewState zero = ViewState(Offset.zero, 0, null);
const ViewState(this.position, this.scale, this.size);
const ViewState(this.position, this.scale, this.viewportSize);
@override
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}';
}
class ViewStateNotification extends Notification {

View file

@ -92,9 +92,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
// cancel notification bubbling so that the info page
// does not misinterpret content scrolling for page scrolling
onNotification: (notification) => true,
child: AnimatedBuilder(
animation: _loadedMetadataUri,
builder: (context, child) {
child: ValueListenableBuilder<String>(
valueListenable: _loadedMetadataUri,
builder: (context, uri, child) {
Widget content;
if (_metadata.isEmpty) {
content = SizedBox.shrink();
@ -119,7 +119,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return AnimationLimiter(
// we update the limiter key after fetching the metadata of a new entry,
// in order to restart the staggered animation of the metadata section
key: Key(_loadedMetadataUri.value),
key: Key(uri),
child: content,
);
},

View file

@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Minimap extends StatelessWidget {
final ImageEntry entry;
@ -22,24 +21,21 @@ class Minimap extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
return CustomPaint(
painter: MinimapPainter(
viewportSize: mqSize,
entrySize: viewState.size ?? entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
);
});
child: ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return SizedBox.shrink();
return CustomPaint(
painter: MinimapPainter(
viewportSize: viewportSize,
entrySize: entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
);
}),
);
}

View file

@ -10,14 +10,12 @@ import 'package:flutter/material.dart';
class TiledImageView extends StatefulWidget {
final ImageEntry entry;
final Size viewportSize;
final ValueNotifier<ViewState> viewStateNotifier;
final Widget baseChild;
final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({
@required this.entry,
@required this.viewportSize,
@required this.viewStateNotifier,
@required this.baseChild,
@required this.errorBuilder,
@ -28,14 +26,13 @@ class TiledImageView extends StatefulWidget {
}
class _TiledImageViewState extends State<TiledImageView> {
bool _initialized = false;
double _tileSide, _initialScale;
int _maxSampleSize;
Matrix4 _transform;
ImageEntry get entry => widget.entry;
Size get viewportSize => widget.viewportSize;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
@ -51,24 +48,21 @@ class _TiledImageViewState extends State<TiledImageView> {
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
_init();
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldViewState.viewportSize != viewState.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
_initialized = false;
}
}
void _init() {
void _initFromViewport(Size viewportSize) {
final displaySize = entry.displaySize;
_tileSide = viewportSize.shortestSide * scaleFactor;
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height);
_initialScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
_maxSampleSize = _sampleSizeForScale(_initialScale);
final rotationDegrees = entry.rotationDegrees;
@ -79,8 +73,9 @@ class _TiledImageViewState extends State<TiledImageView> {
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0);
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
}
_initialized = true;
}
@override
@ -90,10 +85,13 @@ class _TiledImageViewState extends State<TiledImageView> {
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return SizedBox.shrink();
if (!_initialized) _initFromViewport(viewportSize);
var scale = viewState.scale;
if (scale == 0.0) {
// for initial scale as `contained`
@ -136,6 +134,7 @@ class _TiledImageViewState extends State<TiledImageView> {
List<RegionTile> _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) {
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),

View file

@ -1,5 +1,7 @@
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
class SvgBackgroundSelector extends StatefulWidget {
@ -10,33 +12,53 @@ class SvgBackgroundSelector extends StatefulWidget {
class _SvgBackgroundSelectorState extends State<SvgBackgroundSelector> {
@override
Widget build(BuildContext context) {
const radius = 24.0;
const radius = 12.0;
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) {
return DropdownMenuItem<int>(
child: DropdownButton<EntryBackground>(
items: [
EntryBackground.white,
EntryBackground.black,
EntryBackground.checkered,
EntryBackground.transparent,
].map((selected) {
Widget child;
switch (selected) {
case EntryBackground.transparent:
child = Icon(
Icons.clear,
size: 20,
color: Colors.white30,
);
break;
case EntryBackground.checkered:
child = ClipOval(
child: DecoratedBox(
decoration: CheckeredDecoration(
checkSize: radius,
),
),
);
break;
default:
break;
}
return DropdownMenuItem<EntryBackground>(
value: selected,
child: Container(
height: radius,
width: radius,
height: radius * 2,
width: radius * 2,
decoration: BoxDecoration(
color: Color(selected),
color: selected.isColor ? selected.color : null,
border: AvesCircleBorder.build(context),
shape: BoxShape.circle,
),
child: selected == 0
? Icon(
Icons.clear,
size: 20,
color: Colors.white30,
)
: null,
child: child,
),
);
}).toList(),
value: settings.svgBackground,
value: settings.vectorBackground,
onChanged: (selected) {
settings.svgBackground = selected;
settings.vectorBackground = selected;
setState(() {});
},
),