├── 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 | --------------------------------------------------------------------------------