Skip to content

Commit

Permalink
fix: zoom (#559)
Browse files Browse the repository at this point in the history
* fix: zoom

* test: zooming out when viewport is bigger than board

* test: zooming when on bottom right side of the board
  • Loading branch information
jsgalarraga authored Jun 7, 2024
1 parent e1f9ae8 commit 0f1e729
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 53 deletions.
134 changes: 104 additions & 30 deletions lib/crossword/widgets/crossword_interactive_viewer.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:math' as math;

import 'package:flame/extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:game_domain/game_domain.dart';
Expand Down Expand Up @@ -80,49 +81,81 @@ class CrosswordInteractiveViewerState extends State<CrosswordInteractiveViewer>
double get currentScale =>
_transformationController.value.getMaxScaleOnAxis().roundTo(3);

@visibleForTesting
// ignore: use_setters_to_change_properties
void transform(Matrix4 transformation) {
_transformationController.value = transformation;
}

void _onAnimateTransformation() {
_transformationController.value = _transformationAnimation!.value;
}

Rect _getBoardBoundaries(BuildContext context) {
final crosswordLayout = CrosswordLayoutScope.of(context);
final boardWidth = crosswordLayout.padding.left +
crosswordLayout.crosswordSize.width +
crosswordLayout.padding.right;
final boardHeight = crosswordLayout.padding.top +
crosswordLayout.crosswordSize.height +
crosswordLayout.padding.bottom;
return Rect.fromLTRB(0, 0, boardWidth, boardHeight);
}

void _zoom(double value, BuildContext context) {
final animationController = _animationController;
if (animationController.isAnimating) return;

final viewport = _viewport;
if (viewport == null) return;

final layout = IoLayout.of(context);
final viewportSize = viewport.reduced(layout);

final scaleEnd = currentScale + value;

if (scaleEnd < widget.zoomLimit || scaleEnd > _maxScale) return;

final desiredScale = scaleEnd / currentScale;

final viewportCenter = Offset(
viewportSize.width / 2,
viewportSize.height / 2,
);

final beginOffset = _transformationController.toScene(viewportCenter);

final newTransformation = Matrix4.copy(_transformationController.value)
..scale(desiredScale);
final desiredScale = currentScale + value;
final clampedScale = clampDouble(desiredScale, widget.zoomLimit, _maxScale);
var scaleChange = clampedScale / currentScale;

// Calculate the tentative viewport after zooming.
final viewportCenter = viewport.center;
final zoomedViewport = viewport.scaled(scaleChange);
final end = zoomedViewport.center;
var delta = end - viewportCenter;
var newViewportRect = zoomedViewport.toRect().shift(-delta);

final boundaries = _getBoardBoundaries(context);

// If the tentative viewport does not fit in the board, update the zooming
// level and recalculate the viewport to fit.
if (!boundaries.fits(newViewportRect)) {
final scaleRatio = newViewportRect.ratioToFitIn(boundaries);
scaleChange *= scaleRatio;
final fittedViewport = viewport.scaled(scaleChange);
delta = fittedViewport.center - viewportCenter;
newViewportRect = fittedViewport.toRect().shift(-delta);
}

final inverseMatrix = Matrix4.inverted(newTransformation);
final untransformed = inverseMatrix.transform3(
Vector3(
viewportCenter.dx,
viewportCenter.dy,
0,
),
);
final endOffset = Offset(untransformed.x, untransformed.y);
final dx = beginOffset.dx - endOffset.dx;
final dy = beginOffset.dy - endOffset.dy;
// If the new viewport is not entirely within the boundaries, move it.
if (!boundaries.containsComplete(newViewportRect)) {
var (dx, dy) = (0.0, 0.0);

if (newViewportRect.left < boundaries.left) {
dx = boundaries.left - newViewportRect.left;
}
if (newViewportRect.top < boundaries.top) {
dy = boundaries.top - newViewportRect.top;
}
if (newViewportRect.right > boundaries.right) {
dx = boundaries.right - newViewportRect.right;
}
if (newViewportRect.bottom > boundaries.bottom) {
dy = boundaries.bottom - newViewportRect.bottom;
}

delta -= Offset(dx, dy);
}

newTransformation.translate(-dx, -dy);
// Create the scale & translation transformation for the requested zoom.
final newTransformation = _transformationController.value.clone()
..scale(scaleChange)
..translate(delta.dx, delta.dy);

_playTransformation(
_transformationController.value,
Expand Down Expand Up @@ -391,6 +424,47 @@ extension on Quad {
double get width => point2.x - point0.x;

double get height => point2.y - point0.y;

/// The center of the [Quad] in absolute coordinates.
Offset get center => Offset(point0.x + width / 2, point0.y + height / 2);

/// Converts the [Quad] into a [Rect] assuming it is a rectangle defined by
/// `point0` as the top left and `point2` as the bottom right.
Rect toRect() => Rect.fromLTRB(point0.x, point0.y, point2.x, point2.y);

/// Returns a new [Quad] transformed scaling the current one by `scale`.
Quad scaled(double scale) => Quad.copy(this)
// We use the inverse of the scale because when the scale increases
// the viewport is smaller.
..transform(Matrix4.identity().scaled(1 / scale));
}

extension on Rect {
/// Whether the passed `rect` is contained entirely by `this`.
bool containsComplete(Rect rect) {
return rect.left >= left &&
rect.top >= top &&
rect.right <= right &&
rect.bottom <= bottom;
}

/// Whether the passed `rect` can fit inside `this`.
bool fits(Rect rect) {
return rect.width <= width && rect.height <= height;
}

/// Scale ratio to fit `this` within `rect`.
double ratioToFitIn(Rect rect) {
if (rect.width >= width && rect.height >= height) {
return 1;
}

final widthRatio = width / rect.width;
final heightRatio = height / rect.height;
final overflowRatio = math.max(widthRatio, heightRatio);

return overflowRatio;
}
}

extension on SelectedWord {
Expand Down
140 changes: 117 additions & 23 deletions test/crossword/widgets/crossword_interactive_viewer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import 'dart:async';

import 'package:bloc_test/bloc_test.dart';
import 'package:flame/extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
Expand All @@ -11,7 +12,6 @@ import 'package:io_crossword/crossword/crossword.dart';
import 'package:io_crossword/word_selection/word_selection.dart';
import 'package:io_crossword_ui/io_crossword_ui.dart';
import 'package:mocktail/mocktail.dart';
import 'package:vector_math/vector_math_64.dart';

import '../../helpers/helpers.dart';

Expand Down Expand Up @@ -162,34 +162,128 @@ void main() {
});

testWidgets(
'does nothing when zoom out button is pressed and '
'limit has been reached', (tester) async {
const zoomLimit = 0.6;
await tester.pumpSubject(
crosswordBloc: crosswordBloc,
CrosswordInteractiveViewer(
zoomLimit: zoomLimit,
builder: (context, position) {
return const SizedBox();
},
),
);
'keeps same scale when zoom out button is pressed and zoom value limit '
'has been reached',
(tester) async {
const zoomLimit = 0.6;
await tester.pumpSubject(
crosswordBloc: crosswordBloc,
CrosswordInteractiveViewer(
zoomLimit: zoomLimit,
builder: (context, position) {
return const SizedBox();
},
),
);

final viewerState = tester.state<CrosswordInteractiveViewerState>(
find.byType(CrosswordInteractiveViewer),
);
final viewerState = tester.state<CrosswordInteractiveViewerState>(
find.byType(CrosswordInteractiveViewer),
);

while (viewerState.currentScale > zoomLimit) {
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
}
expect(viewerState.currentScale, zoomLimit);

while (viewerState.currentScale > zoomLimit) {
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
}
expect(viewerState.currentScale, zoomLimit);

await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
expect(viewerState.currentScale, zoomLimit);
},
);

expect(viewerState.currentScale, zoomLimit);
});
testWidgets(
'keeps same scale when zoom out button is pressed and board size limit '
'has been reached',
(tester) async {
const zoomLimit = 0.2;
await tester.pumpSubject(
crosswordBloc: crosswordBloc,
CrosswordInteractiveViewer(
zoomLimit: zoomLimit,
builder: (context, position) {
return const SizedBox();
},
),
);

final viewerState = tester.state<CrosswordInteractiveViewerState>(
find.byType(CrosswordInteractiveViewer),
);

var previousScale = 0.0;
while (viewerState.currentScale != previousScale) {
previousScale = viewerState.currentScale;
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
}

expect(viewerState.currentScale >= zoomLimit, isTrue);

await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();

expect(viewerState.currentScale >= zoomLimit, isTrue);
},
);

testWidgets(
'keeps board within boundaries when viewport is in the bottom right and '
'zoom out button is pressed',
(tester) async {
const zoomLimit = 0.2;
late double boardWidth;
late double boardHeight;
late Quad gameViewport;

await tester.pumpSubject(
crosswordBloc: crosswordBloc,
CrosswordInteractiveViewer(
zoomLimit: zoomLimit,
builder: (context, viewport) {
gameViewport = viewport;
final crosswordLayout = CrosswordLayoutScope.of(context);
boardWidth = crosswordLayout.padding.left +
crosswordLayout.crosswordSize.width +
crosswordLayout.padding.right;
boardHeight = crosswordLayout.padding.top +
crosswordLayout.crosswordSize.height +
crosswordLayout.padding.bottom;
return SizedBox(
width: boardWidth,
height: boardHeight,
);
},
),
);

tester
.state<CrosswordInteractiveViewerState>(
find.byType(CrosswordInteractiveViewer),
)
.transform(
Matrix4.identity()
..translate2(
Vector2(
gameViewport.point2.x - boardWidth,
gameViewport.point2.y - boardHeight,
),
),
);

await tester.pumpAndSettle();

expect(gameViewport.point2.x, equals(boardWidth));
expect(gameViewport.point2.y, equals(boardHeight));

await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();

expect(gameViewport.point2.x.roundToDouble(), equals(boardWidth));
expect(gameViewport.point2.y.roundToDouble(), equals(boardHeight));
},
);

testWidgets('zooms out when zoom out button is pressed', (tester) async {
await tester.pumpSubject(
Expand Down

0 comments on commit 0f1e729

Please sign in to comment.