Skip to content

Commit e813edc

Browse files
authored
Merge pull request #690 from code-payments/feat/pinch-to-zoom
feat(scanner): migrate from drag-to-zoom to pinch-to-zoom
2 parents 87f63bd + 4517c62 commit e813edc

5 files changed

Lines changed: 81 additions & 53 deletions

File tree

apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.runtime.DisposableEffect
66
import androidx.compose.runtime.LaunchedEffect
77
import androidx.compose.runtime.collectAsState
88
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableFloatStateOf
910
import androidx.compose.runtime.mutableStateOf
1011
import androidx.compose.runtime.remember
1112
import androidx.compose.runtime.setValue
@@ -59,6 +60,9 @@ internal fun Scanner() {
5960

6061
val vibrator = LocalVibrator.current
6162

63+
var isPinching by remember { mutableStateOf(false) }
64+
var zoomRatio by remember { mutableFloatStateOf(1f) }
65+
6266
LaunchedEffect(biometricsState, previewing) {
6367
if (previewing == true) {
6468
focusManager.clearFocus()
@@ -74,6 +78,8 @@ internal fun Scanner() {
7478
@SuppressLint("LocalContextGetResourceValueCall")
7579
BillContainer(
7680
isPaused = isPaused,
81+
isPinching = isPinching,
82+
zoomRatio = zoomRatio,
7783
onAction = {
7884
when (it) {
7985
ScannerDecorItem.Give -> {
@@ -95,7 +101,10 @@ internal fun Scanner() {
95101
CodeScanner(
96102
scanningEnabled = previewing == true,
97103
cameraGesturesEnabled = true,
98-
invertedDragZoomEnabled = true,
104+
onPinchStateChanged = { pinching, zoom ->
105+
isPinching = pinching
106+
zoomRatio = zoom
107+
},
99108
onPreviewStateChanged = {
100109
cameraAvailable = true
101110
previewing = it

apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import kotlinx.coroutines.delay
6767
internal fun BillContainer(
6868
modifier: Modifier = Modifier,
6969
isPaused: Boolean,
70+
isPinching: Boolean = false,
71+
zoomRatio: Float = 1f,
7072
scannerView: @Composable () -> Unit,
7173
onAction: (ScannerDecorItem) -> Unit
7274
) {
@@ -199,6 +201,8 @@ internal fun BillContainer(
199201
state = updatedState,
200202
billState = updatedBillState,
201203
isPaused = isPaused,
204+
isPinching = isPinching,
205+
zoomRatio = zoomRatio,
202206
onAction = onAction
203207
)
204208
}

apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
1313
import androidx.compose.foundation.layout.Row
1414
import androidx.compose.foundation.layout.WindowInsets
1515
import androidx.compose.foundation.layout.fillMaxSize
16+
import androidx.compose.foundation.layout.fillMaxWidth
1617
import androidx.compose.foundation.layout.navigationBars
1718
import androidx.compose.foundation.layout.padding
1819
import androidx.compose.foundation.layout.size
@@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.width
2122
import androidx.compose.foundation.layout.windowInsetsPadding
2223
import androidx.compose.foundation.layout.wrapContentSize
2324
import androidx.compose.foundation.shape.CircleShape
25+
import androidx.compose.foundation.shape.RoundedCornerShape
2426
import androidx.compose.material.Icon
2527
import androidx.compose.material.Text
2628
import androidx.compose.runtime.Composable
@@ -35,7 +37,9 @@ import androidx.compose.ui.graphics.Color
3537
import androidx.compose.ui.platform.testTag
3638
import androidx.compose.ui.res.painterResource
3739
import androidx.compose.ui.res.stringResource
40+
import androidx.compose.ui.text.font.FontWeight
3841
import androidx.compose.ui.unit.dp
42+
import androidx.compose.ui.unit.sp
3943
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4044
import com.flipcash.app.bill.customization.LocalBillPlaygroundController
4145
import com.flipcash.app.core.bill.BillState
@@ -44,6 +48,7 @@ import com.flipcash.app.session.SessionState
4448
import com.flipcash.features.scanner.R
4549
import com.getcode.theme.CodeTheme
4650
import com.getcode.theme.xxl
51+
import com.getcode.ui.components.Pill
4752
import com.getcode.ui.core.rememberedClickable
4853
import com.getcode.ui.core.unboundedClickable
4954
import com.getcode.utils.network.LocalNetworkObserver
@@ -53,6 +58,8 @@ internal fun DecorView(
5358
state: SessionState,
5459
billState: BillState,
5560
isPaused: Boolean,
61+
isPinching: Boolean = false,
62+
zoomRatio: Float = 1f,
5663
modifier: Modifier = Modifier,
5764
onAction: (ScannerDecorItem) -> Unit,
5865
) {
@@ -111,6 +118,29 @@ internal fun DecorView(
111118
)
112119
}
113120

121+
AnimatedVisibility(
122+
modifier = Modifier
123+
.align(Alignment.TopCenter)
124+
.statusBarsPadding()
125+
.padding(top = CodeTheme.dimens.grid.x9),
126+
visible = isPinching,
127+
enter = fadeIn(tween(150)),
128+
exit = fadeOut(tween(250)),
129+
) {
130+
Box(
131+
modifier = Modifier.fillMaxSize(),
132+
contentAlignment = Alignment.TopCenter,
133+
) {
134+
Pill(
135+
text = "${"%.1f".format(zoomRatio)}x",
136+
textStyle = CodeTheme.typography.textSmall.copy(
137+
fontWeight = FontWeight.Bold
138+
),
139+
shape = CodeTheme.shapes.xxl,
140+
)
141+
}
142+
}
143+
114144
Column(modifier = Modifier.align(Alignment.BottomCenter)) {
115145
val networkState by LocalNetworkObserver.current.state.collectAsState()
116146

ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,24 @@ import androidx.camera.core.Preview
1111
import androidx.camera.lifecycle.ProcessCameraProvider
1212
import androidx.camera.view.PreviewView
1313
import androidx.compose.animation.AnimatedVisibility
14-
import androidx.compose.runtime.DisposableEffect
1514
import androidx.compose.animation.core.tween
1615
import androidx.compose.animation.fadeIn
1716
import androidx.compose.animation.fadeOut
1817
import androidx.compose.foundation.background
1918
import androidx.compose.foundation.layout.Box
2019
import androidx.compose.foundation.layout.fillMaxSize
2120
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.DisposableEffect
2222
import androidx.compose.runtime.LaunchedEffect
2323
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableFloatStateOf
2425
import androidx.compose.runtime.mutableStateOf
2526
import androidx.compose.runtime.remember
2627
import androidx.compose.runtime.rememberCoroutineScope
2728
import androidx.compose.runtime.setValue
2829
import androidx.compose.ui.Modifier
2930
import androidx.compose.ui.geometry.Offset
3031
import androidx.compose.ui.platform.LocalContext
31-
import androidx.compose.ui.platform.testTag
3232
import androidx.compose.ui.viewinterop.AndroidView
3333
import androidx.lifecycle.Lifecycle
3434
import androidx.lifecycle.LifecycleOwner
@@ -59,8 +59,8 @@ import java.util.concurrent.Executors
5959
fun CodeScanner(
6060
scanningEnabled: Boolean,
6161
cameraGesturesEnabled: Boolean,
62-
invertedDragZoomEnabled: Boolean,
6362
modifier: Modifier = Modifier,
63+
onPinchStateChanged: (Boolean, Float) -> Unit,
6464
onPreviewStateChanged: (Boolean) -> Unit,
6565
onCodeScanned: (CodeScanResult) -> Unit,
6666
onError: (Throwable) -> Unit = { },
@@ -98,6 +98,8 @@ fun CodeScanner(
9898
var camera by remember { mutableStateOf<Camera?>(null) }
9999
var autoFocusPoint by remember { mutableStateOf(Offset.Unspecified) }
100100
var gestureController by remember { mutableStateOf<CameraGestureController?>(null) }
101+
var isPinching by remember { mutableStateOf(false) }
102+
var zoomRatio by remember { mutableFloatStateOf(1f) }
101103

102104
val codeAnalyzer = rememberMultiCodeAnalyzer(
103105
onCodeScanned = onCodeScanned,
@@ -164,14 +166,14 @@ fun CodeScanner(
164166
}
165167
}
166168

167-
LaunchedEffect(camera, cameraGesturesEnabled, invertedDragZoomEnabled) {
169+
LaunchedEffect(camera, cameraGesturesEnabled) {
168170
camera?.let {
169171
gestureController = CameraGestureController(
170172
context = context,
171173
cameraControl = it.cameraControl,
172174
cameraInfo = it.cameraInfo,
173175
gesturesEnabled = cameraGesturesEnabled,
174-
invertedDragEnabled = invertedDragZoomEnabled
176+
onPinchStateChanged = onPinchStateChanged,
175177
) { touchedAt ->
176178
autoFocusPoint = touchedAt
177179
previewView.meteringPointFactory.createPoint(touchedAt.x, touchedAt.y)
@@ -281,4 +283,4 @@ suspend fun bindWithRetry(
281283
throw NoCamerasAvailableException()
282284
}
283285

284-
class NoCamerasAvailableException: Throwable()
286+
class NoCamerasAvailableException : Throwable()

ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt

Lines changed: 29 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,24 @@ import androidx.camera.core.CameraInfo
1111
import androidx.camera.core.FocusMeteringAction
1212
import androidx.camera.core.MeteringPoint
1313
import androidx.compose.ui.geometry.Offset
14-
import com.getcode.ui.utils.AnimationUtils
1514
import java.util.concurrent.TimeUnit
15+
import kotlin.math.pow
1616

1717
internal class CameraGestureController(
1818
context: Context,
19-
invertedDragEnabled: Boolean,
2019
private val gesturesEnabled: Boolean,
2120
private val cameraControl: CameraControl,
2221
private val cameraInfo: CameraInfo,
22+
private val onPinchStateChanged: (isPinching: Boolean, zoomRatio: Float) -> Unit = { _, _ -> },
2323
onTap: (Offset) -> MeteringPoint,
2424
) {
2525
private val handler = Handler(Looper.getMainLooper())
26-
private var shouldIgnoreScroll = false
27-
private var resetIgnore: Runnable? = null
2826
private var initialZoomRatio = 0f
2927
private var initialZoomLevel = -1f
30-
private var accumulatedDelta = 0f
28+
private var gestureZoomFactor = 1f
29+
private var cumulativeScale = 1f
30+
private var appliedZoom = 1f
31+
private var isPinching = false
3132

3233
private val maxZoom: Float
3334
get() = maxZoomOrNull ?: 1f
@@ -48,40 +49,40 @@ internal class CameraGestureController(
4849
context,
4950
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
5051
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
51-
shouldIgnoreScroll = true
52-
resetIgnore?.let { handler.removeCallbacks(it) }
52+
isPinching = true
53+
gestureZoomFactor = currentZoom
54+
appliedZoom = currentZoom
55+
cumulativeScale = 1f
56+
onPinchStateChanged(true, currentZoom)
5357
return true
5458
}
5559

5660
override fun onScale(detector: ScaleGestureDetector): Boolean {
57-
val delta = detector.scaleFactor
58-
val newZoomRatio = currentZoom * delta
59-
60-
// Clamp the new zoom ratio between the minimum and maximum zoom ratio
61-
val clampedZoomRatio = newZoomRatio.coerceIn(
61+
cumulativeScale *= detector.scaleFactor
62+
val amplified = cumulativeScale.toDouble().pow(1.3).toFloat()
63+
val targetZoom = (gestureZoomFactor * amplified).coerceIn(
6264
minZoom,
63-
maxZoomOrNull ?: currentZoom
65+
minOf(maxZoom, 20f)
6466
)
65-
66-
// Apply the zoom to the camera control
67-
cameraControl.setZoomRatio(clampedZoomRatio)
67+
// Lerp toward target to smooth lens-switch transitions
68+
appliedZoom += (targetZoom - appliedZoom) * 0.4f
69+
cameraControl.setZoomRatio(appliedZoom)
70+
onPinchStateChanged(true, appliedZoom)
6871
return true
6972
}
7073

7174
override fun onScaleEnd(detector: ScaleGestureDetector) {
7275
initialZoomRatio = currentZoom
73-
resetIgnore = Runnable { shouldIgnoreScroll = false }
74-
resetIgnore?.let { handler.postDelayed(it, 500) }
76+
cumulativeScale = 1f
7577
}
7678
})
7779

78-
// Gesture detector for tap and drag-to-zoom
80+
// Gesture detector for tap-to-focus
7981
private val gestureDetector = GestureDetector(
8082
context,
8183
object : GestureDetector.OnGestureListener {
8284
override fun onDown(e: MotionEvent): Boolean {
8385
initialZoomRatio = currentZoom
84-
accumulatedDelta = 0f
8586
return true
8687
}
8788

@@ -100,27 +101,7 @@ internal class CameraGestureController(
100101
e2: MotionEvent,
101102
distanceX: Float,
102103
distanceY: Float
103-
): Boolean {
104-
if (!shouldIgnoreScroll) {
105-
accumulatedDelta = if (invertedDragEnabled) {
106-
accumulatedDelta + distanceY * 0.5f
107-
} else {
108-
accumulatedDelta - distanceY * 0.5f
109-
}
110-
111-
val zoomDelta = AnimationUtils.ease(
112-
value = accumulatedDelta,
113-
fromRange = 0f..250f,
114-
toRange = 0f..10f,
115-
easeIn = true,
116-
easeOut = false
117-
)
118-
119-
val newZoom = (initialZoomRatio + zoomDelta).coerceIn(minZoom, maxZoom)
120-
cameraControl.setZoomRatio(newZoom)
121-
}
122-
return true
123-
}
104+
): Boolean = false
124105

125106
override fun onShowPress(e: MotionEvent) {}
126107
override fun onLongPress(e: MotionEvent) {}
@@ -129,9 +110,7 @@ internal class CameraGestureController(
129110
e2: MotionEvent,
130111
velocityX: Float,
131112
velocityY: Float
132-
): Boolean {
133-
return false
134-
}
113+
): Boolean = false
135114
}
136115
)
137116

@@ -145,8 +124,12 @@ internal class CameraGestureController(
145124
gestureDetector.onTouchEvent(event)
146125

147126
if (event.action == MotionEvent.ACTION_UP) {
148-
animateZoomReset(cameraInfo, cameraControl)
149-
initialZoomRatio = currentZoom
127+
if (isPinching) {
128+
onPinchStateChanged(false, currentZoom)
129+
animateZoomReset(cameraInfo, cameraControl)
130+
initialZoomRatio = currentZoom
131+
isPinching = false
132+
}
150133
}
151134
}
152135
}

0 commit comments

Comments
 (0)