1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package social.grain
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.ui.components.ImageZoomHost
import social.grain.ui.components.LocalImageZoom
import social.grain.ui.components.ZoomablePhoto
import social.grain.ui.theme.GrainTheme
/**
* Pinch-to-zoom, which is drawn by a host at the root of the app rather than in
* place.
*
* A photo blown up inside the card is clipped twice over — by the pager and by
* the lazy list — so the zoom is a copy in a layer above both. What that buys
* is only visible in a gesture: the state has to end up on the shared host, and
* it has to be given back when the fingers lift.
*/
@RunWith(AndroidJUnit4::class)
class ZoomablePhotoTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun aPhotoOutsideAHostIsStillJustAPhoto() {
composeRule.setContent {
GrainTheme {
ZoomablePhoto(
model = "https://cdn.grain.test/full/0.jpg",
contentDescription = "A tree",
modifier = Modifier.size(200.dp),
)
}
}
composeRule.onNodeWithContentDescription("A tree").assertExists()
}
@Test
fun pinchingInsideAHostHandsTheZoomToTheHostAndGivesItBack() {
lateinit var zoom: social.grain.ui.components.ImageZoomState
composeRule.setContent {
GrainTheme {
ImageZoomHost(Modifier.fillMaxSize()) {
zoom = LocalImageZoom.current!!
ZoomablePhoto(
model = "https://cdn.grain.test/full/0.jpg",
contentDescription = "A tree",
modifier = Modifier.size(200.dp),
)
}
}
}
composeRule.onNodeWithContentDescription("A tree").performTouchInput {
down(0, Offset(width * 0.4f, height * 0.5f))
down(1, Offset(width * 0.6f, height * 0.5f))
moveTo(0, Offset(width * 0.1f, height * 0.5f))
moveTo(1, Offset(width * 0.9f, height * 0.5f))
}
composeRule.waitForIdle()
assertTrue("The fingers spread, so the photo grew", zoom.scale > 1f)
composeRule.onNodeWithContentDescription("A tree").performTouchInput {
up(0)
up(1)
}
composeRule.waitUntil(10_000) { zoom.source == null }
assertEquals(1f, zoom.scale, 0.001f)
}
@Test
fun aPinchOnADisabledPhotoIsIgnored() {
lateinit var zoom: social.grain.ui.components.ImageZoomState
composeRule.setContent {
GrainTheme {
ImageZoomHost(Modifier.fillMaxSize()) {
zoom = LocalImageZoom.current!!
Box(Modifier.size(200.dp)) {
ZoomablePhoto(
model = "https://cdn.grain.test/full/0.jpg",
contentDescription = "Covered",
enabled = false,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
composeRule.onNodeWithContentDescription("Covered").performTouchInput {
down(0, Offset(width * 0.4f, height * 0.5f))
down(1, Offset(width * 0.6f, height * 0.5f))
moveTo(0, Offset(width * 0.1f, height * 0.5f))
moveTo(1, Offset(width * 0.9f, height * 0.5f))
up(0)
up(1)
}
composeRule.waitForIdle()
assertEquals("A covered photo must not zoom past its label", 1f, zoom.scale, 0.001f)
}
}