├── README.md
├── LICENSE
└── Picture.kt
/README.md:
--------------------------------------------------------------------------------
1 | # CoilWrapper
2 | Jetpack compose coil wrapper with additional functional like zooming and shimmering image while loading
3 |
4 | Copy Picture.kt file to your repository and you are ready to engage!
5 |
6 | ```kotlin
7 | //Usage example
8 | Picture(
9 | model = "https://avatars.githubusercontent.com/u/52178347?v=4",
10 | modifier = Modifier.size(100.dp),
11 | /*your other params*/
12 | )
13 | ```
14 |
15 | ## Note
16 | Also remember to add this dependencies to local build.gradle folder
17 |
18 | ```kotlin
19 | //Accompanist
20 | implementation("com.google.accompanist:accompanist-placeholder-material:0.28.0")
21 |
22 | //Coil
23 | implementation("io.coil-kt:coil:2.2.2")
24 | implementation("io.coil-kt:coil-compose:2.2.2")
25 | implementation("io.coil-kt:coil-gif:2.2.2")
26 | implementation("io.coil-kt:coil-svg:2.2.2")
27 | ```
28 |
29 | ## Find this repository useful? :heart:
30 | Support it by joining __[stargazers](https://github.com/t8rin/coilwrapper/stargazers)__ for this repository. :star:
31 | And __[follow](https://github.com/t8rin)__ me for my next creations! 🤩
32 |
33 | # License
34 | ```xml
35 | Designed and developed by 2022 T8RIN
36 |
37 | Licensed under the Apache License, Version 2.0 (the "License");
38 | you may not use this file except in compliance with the License.
39 | You may obtain a copy of the License at
40 |
41 | http://www.apache.org/licenses/LICENSE-2.0
42 |
43 | Unless required by applicable law or agreed to in writing, software
44 | distributed under the License is distributed on an "AS IS" BASIS,
45 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
46 | See the License for the specific language governing permissions and
47 | limitations under the License.
48 | ```
49 |
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/Picture.kt:
--------------------------------------------------------------------------------
1 | import android.app.Activity
2 | import android.content.Context
3 | import android.content.ContextWrapper
4 | import android.content.res.Configuration
5 | import android.os.Build.VERSION.SDK_INT
6 | import androidx.annotation.FloatRange
7 | import androidx.compose.animation.core.Animatable
8 | import androidx.compose.animation.core.AnimationSpec
9 | import androidx.compose.animation.core.exponentialDecay
10 | import androidx.compose.animation.core.spring
11 | import androidx.compose.foundation.gestures.*
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.BoxScope
14 | import androidx.compose.foundation.layout.BoxWithConstraints
15 | import androidx.compose.foundation.shape.CircleShape
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.runtime.*
18 | import androidx.compose.runtime.saveable.Saver
19 | import androidx.compose.runtime.saveable.listSaver
20 | import androidx.compose.runtime.saveable.rememberSaveable
21 | import androidx.compose.ui.Alignment
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.draw.clip
24 | import androidx.compose.ui.geometry.Offset
25 | import androidx.compose.ui.graphics.*
26 | import androidx.compose.ui.graphics.drawscope.DrawScope
27 | import androidx.compose.ui.input.pointer.PointerInputChange
28 | import androidx.compose.ui.input.pointer.PointerInputScope
29 | import androidx.compose.ui.input.pointer.pointerInput
30 | import androidx.compose.ui.input.pointer.positionChange
31 | import androidx.compose.ui.input.pointer.util.VelocityTracker
32 | import androidx.compose.ui.layout.ContentScale
33 | import androidx.compose.ui.layout.layout
34 | import androidx.compose.ui.platform.LocalConfiguration
35 | import androidx.compose.ui.platform.LocalContext
36 | import androidx.compose.ui.platform.LocalDensity
37 | import androidx.compose.ui.unit.Density
38 | import androidx.compose.ui.unit.Dp
39 | import androidx.compose.ui.unit.dp
40 | import androidx.core.view.WindowInsetsCompat
41 | import androidx.core.view.WindowInsetsControllerCompat
42 | import coil.ImageLoader
43 | import coil.compose.AsyncImagePainter
44 | import coil.compose.SubcomposeAsyncImage
45 | import coil.compose.SubcomposeAsyncImageScope
46 | import coil.decode.GifDecoder
47 | import coil.decode.ImageDecoderDecoder
48 | import coil.decode.SvgDecoder
49 | import coil.imageLoader
50 | import coil.request.ImageRequest
51 | import coil.transform.Transformation
52 | import com.google.accompanist.placeholder.PlaceholderHighlight
53 | import com.google.accompanist.placeholder.material.shimmer
54 | import com.google.accompanist.placeholder.placeholder
55 | import kotlinx.coroutines.coroutineScope
56 | import kotlinx.coroutines.launch
57 |
58 |
59 | @Composable
60 | fun Picture(
61 | modifier: Modifier = Modifier,
62 | model: Any?,
63 | transformations: List = emptyList(),
64 | manualImageRequest: ImageRequest? = null,
65 | manualImageLoader: ImageLoader? = null,
66 | contentDescription: String? = null,
67 | shape: Shape = CircleShape,
68 | contentScale: ContentScale = ContentScale.Crop,
69 | loading: @Composable (SubcomposeAsyncImageScope.(AsyncImagePainter.State.Loading) -> Unit)? = null,
70 | success: @Composable (SubcomposeAsyncImageScope.(AsyncImagePainter.State.Success) -> Unit)? = null,
71 | error: @Composable (SubcomposeAsyncImageScope.(AsyncImagePainter.State.Error) -> Unit)? = null,
72 | onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
73 | onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
74 | onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
75 | onState: ((AsyncImagePainter.State) -> Unit)? = null,
76 | alignment: Alignment = Alignment.Center,
77 | alpha: Float = DefaultAlpha,
78 | colorFilter: ColorFilter? = null,
79 | filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
80 | zoomParams: ZoomParams = ZoomParams(),
81 | shimmerEnabled: Boolean = true,
82 | crossfadeEnabled: Boolean = true,
83 | allowHardware: Boolean = true,
84 | ) {
85 | val activity = LocalContext.current.findActivity()
86 | val context = LocalContext.current
87 |
88 | var errorOccurred by rememberSaveable { mutableStateOf(false) }
89 |
90 | var shimmerVisible by rememberSaveable { mutableStateOf(true) }
91 |
92 | val imageLoader =
93 | manualImageLoader ?: context.imageLoader.newBuilder().components {
94 | if (SDK_INT >= 28) add(ImageDecoderDecoder.Factory())
95 | else add(GifDecoder.Factory())
96 | add(SvgDecoder.Factory())
97 | }.build()
98 |
99 | val request = manualImageRequest ?: ImageRequest.Builder(context)
100 | .data(model)
101 | .crossfade(crossfadeEnabled)
102 | .allowHardware(allowHardware)
103 | .transformations(transformations)
104 | .build()
105 |
106 | val image: @Composable () -> Unit = {
107 | SubcomposeAsyncImage(
108 | model = request,
109 | imageLoader = imageLoader,
110 | contentDescription = contentDescription,
111 | modifier = modifier
112 | .clip(shape)
113 | .then(if (shimmerEnabled) Modifier.shimmer(shimmerVisible) else Modifier),
114 | contentScale = contentScale,
115 | loading = {
116 | if (loading != null) loading(it)
117 | shimmerVisible = true
118 | },
119 | success = success,
120 | error = error,
121 | onSuccess = {
122 | shimmerVisible = false
123 | onSuccess?.invoke(it)
124 | onState?.invoke(it)
125 | },
126 | onLoading = {
127 | onLoading?.invoke(it)
128 | onState?.invoke(it)
129 | },
130 | onError = {
131 | if (error != null) shimmerVisible = false
132 | onError?.invoke(it)
133 | onState?.invoke(it)
134 | errorOccurred = true
135 | },
136 | alignment = alignment,
137 | alpha = alpha,
138 | colorFilter = colorFilter,
139 | filterQuality = filterQuality
140 | )
141 | }
142 |
143 | if (zoomParams.zoomEnabled) {
144 | Zoomable(
145 | state = rememberZoomableState(
146 | minScale = zoomParams.minZoomScale,
147 | maxScale = zoomParams.maxZoomScale
148 | ),
149 | onTap = {
150 | if (zoomParams.hideBarsOnTap) {
151 | activity?.apply { if (isSystemBarsHidden) showSystemBars() else hideSystemBars() }
152 | }
153 | zoomParams.onTap(it)
154 | },
155 | content = { image() }
156 | )
157 | } else image()
158 |
159 | //Needed for triggering recomposition
160 | LaunchedEffect(errorOccurred) {
161 | if (errorOccurred && error == null) {
162 | shimmerVisible = false
163 | shimmerVisible = true
164 | errorOccurred = false
165 | }
166 | }
167 |
168 | }
169 |
170 | object StatusBarUtils {
171 |
172 | val Activity.isSystemBarsHidden: Boolean
173 | get() {
174 | return _isSystemBarsHidden
175 | }
176 |
177 | private var _isSystemBarsHidden = false
178 |
179 | fun Activity.hideSystemBars() = WindowInsetsControllerCompat(
180 | window,
181 | window.decorView
182 | ).let { controller ->
183 | controller.systemBarsBehavior =
184 | WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
185 | controller.hide(WindowInsetsCompat.Type.systemBars())
186 | _isSystemBarsHidden = true
187 | }
188 |
189 | fun Activity.showSystemBars() = WindowInsetsControllerCompat(
190 | window,
191 | window.decorView
192 | ).show(WindowInsetsCompat.Type.systemBars()).also {
193 | _isSystemBarsHidden = false
194 | }
195 | }
196 |
197 | data class ZoomParams(
198 | val zoomEnabled: Boolean = false,
199 | val hideBarsOnTap: Boolean = false,
200 | val minZoomScale: Float = 1f,
201 | val maxZoomScale: Float = 4f,
202 | val onTap: (Offset) -> Unit = {}
203 | )
204 |
205 | @Composable
206 | fun Zoomable(
207 | state: ZoomableState,
208 | modifier: Modifier = Modifier,
209 | enable: Boolean = true,
210 | onTap: (Offset) -> Unit = { },
211 | content: @Composable BoxScope.() -> Unit,
212 | ) {
213 | val scope = rememberCoroutineScope()
214 | val conf = LocalConfiguration.current
215 | val density = LocalDensity.current
216 |
217 | BoxWithConstraints(
218 | modifier = modifier,
219 | ) {
220 | var childWidth by remember { mutableStateOf(0) }
221 | var childHeight by remember { mutableStateOf(0) }
222 | LaunchedEffect(
223 | childHeight,
224 | childWidth,
225 | state.scale,
226 | ) {
227 | val maxX = (childWidth * state.scale - constraints.maxWidth)
228 | .coerceAtLeast(0F) / 2F
229 | val maxY = (childHeight * state.scale - constraints.maxHeight)
230 | .coerceAtLeast(0F) / 2F
231 | state.updateBounds(maxX, maxY)
232 | }
233 | val transformableState = rememberTransformableState { zoomChange, _, _ ->
234 | if (enable) scope.launch { state.onZoomChange(zoomChange) }
235 | }
236 | val doubleTapModifier = if (enable) {
237 | Modifier.pointerInput(Unit) {
238 | detectTapGestures(
239 | onDoubleTap = {
240 | scope.launch {
241 | if (state.scale == state.maxScale) state.animateScaleTo(state.minScale)
242 | else {
243 | state.animateScaleTo(state.scale + (state.maxScale - state.minScale) / 2)
244 | state.setTapOffset(it, conf, density)
245 | }
246 | }
247 | },
248 | onTap = onTap
249 | )
250 | }
251 | } else Modifier
252 |
253 | Box(
254 | modifier = Modifier
255 | .pointerInput(Unit) {
256 | detectDrag(
257 | onDrag = { change, dragAmount ->
258 | if (state.zooming && enable) {
259 | if (change.positionChange() != Offset.Zero) change.consume()
260 | scope.launch {
261 | state.drag(dragAmount)
262 | state.addPosition(
263 | change.uptimeMillis,
264 | change.position
265 | )
266 | }
267 | }
268 | },
269 | onDragCancel = {
270 | if (enable) state.resetTracking()
271 | },
272 | onDragEnd = {
273 | if (state.zooming && enable) {
274 | scope.launch { state.dragEnd() }
275 | }
276 | },
277 | )
278 | }
279 | .then(doubleTapModifier)
280 | .transformable(state = transformableState)
281 | .layout { measurable, constraints ->
282 | val placeable =
283 | measurable.measure(constraints = constraints)
284 | childHeight = placeable.height
285 | childWidth = placeable.width
286 | layout(
287 | width = constraints.maxWidth,
288 | height = constraints.maxHeight
289 | ) {
290 | placeable.placeRelativeWithLayer(
291 | (constraints.maxWidth - placeable.width) / 2,
292 | (constraints.maxHeight - placeable.height) / 2
293 | ) {
294 | scaleX = state.scale
295 | scaleY = state.scale
296 | translationX = state.translateX
297 | translationY = state.translateY
298 | }
299 | }
300 | }
301 | ) {
302 | content.invoke(this)
303 | }
304 | }
305 | }
306 |
307 |
308 | private suspend fun PointerInputScope.detectDrag(
309 | onDragStart: (Offset) -> Unit = { },
310 | onDragEnd: () -> Unit = { },
311 | onDragCancel: () -> Unit = { },
312 | onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
313 | ) {
314 | forEachGesture {
315 | awaitPointerEventScope {
316 | val down = awaitFirstDown(requireUnconsumed = false)
317 | var drag: PointerInputChange?
318 | do {
319 | drag = awaitTouchSlopOrCancellation(down.id, onDrag)
320 | } while (drag != null && !drag.isConsumed)
321 | if (drag != null) {
322 | onDragStart.invoke(drag.position)
323 | if (!drag(drag.id) { onDrag(it, it.positionChange()) }) onDragCancel()
324 | else onDragEnd()
325 | }
326 | }
327 | }
328 | }
329 |
330 | @Composable
331 | fun rememberZoomableState(
332 | @FloatRange(from = 0.0) minScale: Float = 1f,
333 | @FloatRange(from = 0.0) maxScale: Float = Float.MAX_VALUE,
334 | ): ZoomableState = rememberSaveable(
335 | saver = ZoomableState.Saver
336 | ) {
337 | ZoomableState(
338 | minScale = minScale,
339 | maxScale = maxScale,
340 | )
341 | }
342 |
343 | /**
344 | * A state object that can be hoisted to observe scale and translate for [Zoomable].
345 | *
346 | * In most cases, this will be created via [rememberZoomableState].
347 | *
348 | * @param minScale the minimum scale value for [ZoomableState.minScale]
349 | * @param maxScale the maximum scale value for [ZoomableState.maxScale]
350 | * @param initialTranslateX the initial translateX value for [ZoomableState.translateX]
351 | * @param initialTranslateY the initial translateY value for [ZoomableState.translateY]
352 | * @param initialScale the initial scale value for [ZoomableState.scale]
353 | */
354 | @Stable
355 | class ZoomableState(
356 | @FloatRange(from = 0.0) val minScale: Float = 1f,
357 | @FloatRange(from = 0.0) val maxScale: Float = Float.MAX_VALUE,
358 | @FloatRange(from = 0.0) initialTranslateX: Float = 0f,
359 | @FloatRange(from = 0.0) initialTranslateY: Float = 0f,
360 | @FloatRange(from = 0.0) initialScale: Float = minScale,
361 | ) {
362 | private val velocityTracker = VelocityTracker()
363 | private val _translateY = Animatable(initialTranslateY)
364 | private val _translateX = Animatable(initialTranslateX)
365 | private val _scale = Animatable(initialScale)
366 |
367 | init {
368 | require(minScale < maxScale) { "minScale must be < maxScale" }
369 | }
370 |
371 | /**
372 | * The current scale value for [Zoomable]
373 | */
374 | @get:FloatRange(from = 0.0)
375 | val scale: Float
376 | get() = _scale.value
377 |
378 | /**
379 | * The current translateY value for [Zoomable]
380 | */
381 | @get:FloatRange(from = 0.0)
382 | val translateY: Float
383 | get() = _translateY.value
384 |
385 | /**
386 | * The current translateX value for [Zoomable]
387 | */
388 | @get:FloatRange(from = 0.0)
389 | val translateX: Float
390 | get() = _translateX.value
391 |
392 | internal val zooming: Boolean
393 | get() = scale > minScale
394 |
395 | /**
396 | * Instantly sets scale of [Zoomable] to given [scale]
397 | */
398 | suspend fun snapScaleTo(scale: Float) = coroutineScope {
399 | _scale.snapTo(scale.coerceIn(minimumValue = minScale, maximumValue = maxScale))
400 | }
401 |
402 | /**
403 | * Animates scale of [Zoomable] to given [scale]
404 | */
405 | suspend fun animateScaleTo(
406 | scale: Float,
407 | animationSpec: AnimationSpec = spring(),
408 | initialVelocity: Float = 0f,
409 | ) = coroutineScope {
410 | _scale.animateTo(
411 | targetValue = scale.coerceIn(minimumValue = minScale, maximumValue = maxScale),
412 | animationSpec = animationSpec,
413 | initialVelocity = initialVelocity,
414 | )
415 | }
416 |
417 | private suspend fun fling(velocity: Offset) = coroutineScope {
418 | launch {
419 | _translateY.animateDecay(
420 | velocity.y / 2f,
421 | exponentialDecay()
422 | )
423 | }
424 | launch {
425 | _translateX.animateDecay(
426 | velocity.x / 2f,
427 | exponentialDecay()
428 | )
429 | }
430 | }
431 |
432 | internal suspend fun drag(dragDistance: Offset) = coroutineScope {
433 | launch {
434 | _translateY.snapTo(_translateY.value + dragDistance.y)
435 | }
436 | launch {
437 | _translateX.snapTo(_translateX.value + dragDistance.x)
438 | }
439 | }
440 |
441 | internal suspend fun dragEnd() {
442 | val velocity = velocityTracker.calculateVelocity()
443 | fling(Offset(velocity.x, velocity.y))
444 | }
445 |
446 | internal suspend fun updateBounds(maxX: Float, maxY: Float) = coroutineScope {
447 | _translateY.updateBounds(-maxY, maxY)
448 | _translateX.updateBounds(-maxX, maxX)
449 | }
450 |
451 | internal suspend fun onZoomChange(zoomChange: Float) = snapScaleTo(scale * zoomChange)
452 |
453 | internal fun addPosition(timeMillis: Long, position: Offset) {
454 | velocityTracker.addPosition(timeMillis = timeMillis, position = position)
455 | }
456 |
457 | internal fun resetTracking() = velocityTracker.resetTracking()
458 |
459 | override fun toString(): String = "ZoomableState(" +
460 | "minScale=$minScale, " +
461 | "maxScale=$maxScale, " +
462 | "translateY=$translateY" +
463 | "translateX=$translateX" +
464 | "scale=$scale" +
465 | ")"
466 |
467 | suspend fun setTapOffset(tapOffset: Offset, configuration: Configuration, density: Density) =
468 | coroutineScope {
469 | val targetOffset: Offset = calcTargetOffset(tapOffset, configuration, density)
470 | launch {
471 | _translateX.animateTo(_translateX.value + targetOffset.x)
472 | }
473 | launch {
474 | _translateY.animateTo(_translateY.value + targetOffset.y)
475 | }
476 | }
477 |
478 | private fun calcTargetOffset(
479 | tapOffset: Offset,
480 | configuration: Configuration,
481 | density: Density
482 | ): Offset {
483 | val width = configuration.screenWidthDp.dp.toPx(density)
484 | val height = configuration.screenHeightDp.dp.toPx(density)
485 |
486 | val halfWidth = width / 2
487 | val halfHeight = height / 2
488 |
489 | val x = halfWidth - tapOffset.x
490 | val y = halfHeight - tapOffset.y
491 |
492 | return Offset(x, y)
493 | }
494 |
495 | companion object {
496 | /**
497 | * The default [Saver] implementation for [ZoomableState].
498 | */
499 | val Saver: Saver = listSaver(
500 | save = {
501 | listOf(
502 | it.translateX,
503 | it.translateY,
504 | it.scale,
505 | it.minScale,
506 | it.maxScale,
507 | )
508 | },
509 | restore = {
510 | ZoomableState(
511 | initialTranslateX = it[0],
512 | initialTranslateY = it[1],
513 | initialScale = it[2],
514 | minScale = it[3],
515 | maxScale = it[4],
516 | )
517 | }
518 | )
519 | }
520 | }
521 |
522 | private fun Dp.toPx(density: Density): Float {
523 | return with(density) { toPx() }
524 | }
525 |
526 | fun Context.findActivity(): Activity? = when (this) {
527 | is Activity -> this
528 | is ContextWrapper -> baseContext.findActivity()
529 | else -> null
530 | }
531 |
532 | @Composable
533 | fun Modifier.shimmer(
534 | visible: Boolean,
535 | color: Color = MaterialTheme.colorScheme.surfaceVariant
536 | ) = then(
537 | Modifier.placeholder(
538 | visible = visible,
539 | color = color,
540 | highlight = PlaceholderHighlight.shimmer()
541 | )
542 | )
543 |
544 |
--------------------------------------------------------------------------------