diff --git a/lib/src/mobile_scanner.dart b/lib/src/mobile_scanner.dart index fa1142d3e..ffd0944cd 100644 --- a/lib/src/mobile_scanner.dart +++ b/lib/src/mobile_scanner.dart @@ -6,6 +6,7 @@ import 'package:mobile_scanner/src/mobile_scanner_controller.dart'; import 'package:mobile_scanner/src/mobile_scanner_exception.dart'; import 'package:mobile_scanner/src/objects/barcode_capture.dart'; import 'package:mobile_scanner/src/objects/mobile_scanner_arguments.dart'; +import 'package:mobile_scanner/src/scan_window_calculation.dart'; /// The function signature for the error builder. typedef MobileScannerErrorBuilder = Widget Function( @@ -175,75 +176,6 @@ class _MobileScannerState extends State } } - /// the [scanWindow] rect will be relative and scaled to the [widgetSize] not the texture. so it is possible, - /// depending on the [fit], for the [scanWindow] to partially or not at all overlap the [textureSize] - /// - /// since when using a [BoxFit] the content will always be centered on its parent. we can convert the rect - /// to be relative to the texture. - /// - /// since the textures size and the actuall image (on the texture size) might not be the same, we also need to - /// calculate the scanWindow in terms of percentages of the texture, not pixels. - Rect calculateScanWindowRelativeToTextureInPercentage( - BoxFit fit, - Rect scanWindow, - Size textureSize, - Size widgetSize, - ) { - double fittedTextureWidth; - double fittedTextureHeight; - - switch (fit) { - case BoxFit.contain: - final widthRatio = widgetSize.width / textureSize.width; - final heightRatio = widgetSize.height / textureSize.height; - final scale = widthRatio < heightRatio ? widthRatio : heightRatio; - fittedTextureWidth = textureSize.width * scale; - fittedTextureHeight = textureSize.height * scale; - break; - - case BoxFit.cover: - final widthRatio = widgetSize.width / textureSize.width; - final heightRatio = widgetSize.height / textureSize.height; - final scale = widthRatio > heightRatio ? widthRatio : heightRatio; - fittedTextureWidth = textureSize.width * scale; - fittedTextureHeight = textureSize.height * scale; - break; - - case BoxFit.fill: - fittedTextureWidth = widgetSize.width; - fittedTextureHeight = widgetSize.height; - break; - - case BoxFit.fitHeight: - final ratio = widgetSize.height / textureSize.height; - fittedTextureWidth = textureSize.width * ratio; - fittedTextureHeight = widgetSize.height; - break; - - case BoxFit.fitWidth: - final ratio = widgetSize.width / textureSize.width; - fittedTextureWidth = widgetSize.width; - fittedTextureHeight = textureSize.height * ratio; - break; - - case BoxFit.none: - case BoxFit.scaleDown: - fittedTextureWidth = textureSize.width; - fittedTextureHeight = textureSize.height; - break; - } - - final offsetX = (widgetSize.width - fittedTextureWidth) / 2; - final offsetY = (widgetSize.height - fittedTextureHeight) / 2; - - final left = (scanWindow.left - offsetX) / fittedTextureWidth; - final top = (scanWindow.top - offsetY) / fittedTextureHeight; - final right = (scanWindow.right - offsetX) / fittedTextureWidth; - final bottom = (scanWindow.bottom - offsetY) / fittedTextureHeight; - - return Rect.fromLTRB(left, top, right, bottom); - } - Rect? scanWindow; @override @@ -261,8 +193,8 @@ class _MobileScannerState extends State scanWindow = calculateScanWindowRelativeToTextureInPercentage( widget.fit, widget.scanWindow!, - value.size, - Size(constraints.maxWidth, constraints.maxHeight), + textureSize: value.size, + widgetSize: constraints.biggest, ); _controller.updateScanWindow(scanWindow); diff --git a/lib/src/scan_window_calculation.dart b/lib/src/scan_window_calculation.dart new file mode 100644 index 000000000..128a2e5f7 --- /dev/null +++ b/lib/src/scan_window_calculation.dart @@ -0,0 +1,97 @@ +import 'dart:math'; + +import 'package:flutter/rendering.dart'; + +/// Calculate the scan window rectangle relative to the texture size. +/// +/// The [scanWindow] rectangle will be relative and scaled to [widgetSize], not [textureSize]. +/// Depending on the given [fit], the [scanWindow] can partially overlap the [textureSize], +/// or not at all. +/// +/// Due to using [BoxFit] the content will always be centered on its parent, +/// which enables converting the rectangle to be relative to the texture. +/// +/// Because the size of the actual texture and the size of the texture in widget-space +/// can be different, calculate the size of the scan window in percentages, +/// rather than pixels. +/// +/// Returns a [Rect] that represents the position and size of the scan window in the texture. +Rect calculateScanWindowRelativeToTextureInPercentage( + BoxFit fit, + Rect scanWindow, { + required Size textureSize, + required Size widgetSize, +}) { + // Convert the texture size to a size in widget-space, with the box fit applied. + final fittedTextureSize = applyBoxFit(fit, textureSize, widgetSize); + + // Get the correct scaling values depending on the given BoxFit mode + double sx = fittedTextureSize.destination.width / textureSize.width; + double sy = fittedTextureSize.destination.height / textureSize.height; + + switch (fit) { + case BoxFit.fill: + // No-op, just use sx and sy. + break; + case BoxFit.contain: + final s = min(sx, sy); + sx = s; + sy = s; + break; + case BoxFit.cover: + final s = max(sx, sy); + sx = s; + sy = s; + break; + case BoxFit.fitWidth: + sy = sx; + break; + case BoxFit.fitHeight: + sx = sy; + break; + case BoxFit.none: + sx = 1.0; + sy = 1.0; + break; + case BoxFit.scaleDown: + final s = min(sx, sy); + sx = s; + sy = s; + break; + } + + // Fit the texture size to the widget rectangle given by the scaling values above. + final textureWindow = Alignment.center.inscribe( + Size(textureSize.width * sx, textureSize.height * sy), + Rect.fromLTWH(0, 0, widgetSize.width, widgetSize.height), + ); + + // Transform the scan window from widget coordinates to texture coordinates. + final scanWindowInTexSpace = Rect.fromLTRB( + (1 / sx) * (scanWindow.left - textureWindow.left), + (1 / sy) * (scanWindow.top - textureWindow.top), + (1 / sx) * (scanWindow.right - textureWindow.left), + (1 / sy) * (scanWindow.bottom - textureWindow.top), + ); + + // Clip the scan window in texture coordinates with the texture bounds. + // This prevents percentages outside the range [0; 1]. + final clippedScanWndInTexSpace = scanWindowInTexSpace.intersect( + Rect.fromLTWH(0, 0, textureSize.width, textureSize.height), + ); + + // Compute relative rectangle coordinates, + // with respect to the texture size, i.e. scan image. + final percentageLeft = clippedScanWndInTexSpace.left / textureSize.width; + final percentageTop = clippedScanWndInTexSpace.top / textureSize.height; + final percentageRight = clippedScanWndInTexSpace.right / textureSize.width; + final percentageBottom = clippedScanWndInTexSpace.bottom / textureSize.height; + + // This rectangle can be used to cut out a rectangle of the scan image. + return Rect.fromLTRB( + percentageLeft, + percentageTop, + percentageRight, + percentageBottom, + ); +} diff --git a/test/scan_window_test.dart b/test/scan_window_test.dart new file mode 100644 index 000000000..54718c628 --- /dev/null +++ b/test/scan_window_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_scanner/src/scan_window_calculation.dart'; + +void main() { + group( + 'Scan window relative to texture', + () { + group('Widget (landscape) smaller than texture (portrait)', () { + const textureSize = Size(480.0, 640.0); + const widgetSize = Size(432.0, 256.0); + final ctx = ScanWindowTestContext( + textureSize: textureSize, + widgetSize: widgetSize, + scanWindow: Rect.fromLTWH( + widgetSize.width / 4, + widgetSize.height / 4, + widgetSize.width / 2, + widgetSize.height / 2, + ), + ); + + test('wl tp: BoxFit.none', () { + ctx.testScanWindow( + BoxFit.none, + const Rect.fromLTRB(0.275, 0.4, 0.725, 0.6), + ); + }); + + test('wl tp: BoxFit.fill', () { + ctx.testScanWindow( + BoxFit.fill, + const Rect.fromLTRB(0.25, 0.25, 0.75, 0.75), + ); + }); + + test('wl tp: BoxFit.fitHeight', () { + ctx.testScanWindow( + BoxFit.fitHeight, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + + test('wl tp: BoxFit.fitWidth', () { + ctx.testScanWindow( + BoxFit.fitWidth, + const Rect.fromLTRB( + 0.25, + 0.38888888888888895, + 0.75, + 0.6111111111111112, + ), + ); + }); + + test('wl tp: BoxFit.cover', () { + // equal to fitWidth + ctx.testScanWindow( + BoxFit.cover, + const Rect.fromLTRB( + 0.25, + 0.38888888888888895, + 0.75, + 0.6111111111111112, + ), + ); + }); + + test('wl tp: BoxFit.contain', () { + // equal to fitHeigth + ctx.testScanWindow( + BoxFit.contain, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + + test('wl tp: BoxFit.scaleDown', () { + // equal to fitHeigth, contain + ctx.testScanWindow( + BoxFit.scaleDown, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + }); + + group('Widget (landscape) smaller than texture and texture (landscape)', + () { + const textureSize = Size(640.0, 480.0); + const widgetSize = Size(320.0, 120.0); + final ctx = ScanWindowTestContext( + textureSize: textureSize, + widgetSize: widgetSize, + scanWindow: Rect.fromLTWH( + widgetSize.width / 4, + widgetSize.height / 4, + widgetSize.width / 2, + widgetSize.height / 2, + ), + ); + + test('wl tl: BoxFit.none', () { + ctx.testScanWindow( + BoxFit.none, + const Rect.fromLTRB(0.375, 0.4375, 0.625, 0.5625), + ); + }); + + test('wl tl: BoxFit.fill', () { + ctx.testScanWindow( + BoxFit.fill, + const Rect.fromLTRB(0.25, 0.25, 0.75, 0.75), + ); + }); + + test('wl tl: BoxFit.fitHeight', () { + ctx.testScanWindow( + BoxFit.fitHeight, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + + test('wl tl: BoxFit.fitWidth', () { + ctx.testScanWindow( + BoxFit.fitWidth, + const Rect.fromLTRB(0.25, 0.375, 0.75, 0.625), + ); + }); + + test('wl tl: BoxFit.cover', () { + // equal to fitWidth + ctx.testScanWindow( + BoxFit.cover, + const Rect.fromLTRB(0.25, 0.375, 0.75, 0.625), + ); + }); + + test('wl tl: BoxFit.contain', () { + // equal to fitHeigth + ctx.testScanWindow( + BoxFit.contain, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + + test('wl tl: BoxFit.scaleDown', () { + // equal to fitHeigth, contain + ctx.testScanWindow( + BoxFit.scaleDown, + const Rect.fromLTRB(0.0, 0.25, 1.0, 0.75), + ); + }); + }); + }, + ); +} + +class ScanWindowTestContext { + ScanWindowTestContext({ + required this.textureSize, + required this.widgetSize, + required this.scanWindow, + }); + + final Size textureSize; + final Size widgetSize; + final Rect scanWindow; + + void testScanWindow(BoxFit fit, Rect expected) { + final actual = calculateScanWindowRelativeToTextureInPercentage( + fit, + scanWindow, + textureSize: textureSize, + widgetSize: widgetSize, + ); + + // don't use expect(actual, expected) because Rect.toString() only shows one digit after the comma which can be confusing + expect(actual.left, expected.left); + expect(actual.top, expected.top); + expect(actual.right, expected.right); + expect(actual.bottom, expected.bottom); + } +}