美文网首页
Compose版来啦!高仿微信朋友圈大图缩放、切换、预览功能

Compose版来啦!高仿微信朋友圈大图缩放、切换、预览功能

作者: kevinsEegets | 来源:发表于2022-04-27 17:13 被阅读0次

    掘金迁移地址

    最近在学习Jetpack Compose,想着能否用Jetpack Compose实现微信一些重要界面以及功能。好消息是已经实现了微信聊天界面相关功能以及交互,最近又搞了搞朋友圈的整体交互,网上看了看,关于compose动画相关知识比较少,所以打算通过最近学习的compose手势动画相关知识实现该功能。

    本文主要讲述如何通过compose手势动画实现微信大图缩放、切换、预览功能。

    废话不多说,先上动图

    由于简书图片或gif上传限制为10M,所以此处无法查看预览图,如想查看预览动图,请移步开头的 掘金迁移地址

    在实现上述功能时首先我们需要了解一下 Compose 为我们提供的一些手势动画。

    使用 PointerInput Modifier

    对于所有手势操作的处理都需要封装到这个 Modifier 中,我们知道 Modifier 时用来修饰 UI 组件的,所以将手势操作的处理封装在 Modifier 符合开发者设计直觉,这同时也做到了手势处理逻辑与 UI 视图的解耦,从而提高复用性。

    Modifier 为我们提供了很多手势事件,比如:Transformer ModifierDraggable ModifierRotation Modifier以及滚动事件点击事件等等都能看到PointerInput Modifier的身影。因为这类上层的手势处理 Modifier 都是基于基础Modifier.pointInput()来实现的,所以自定义手势必然要在这个 Modifier 中进行。

    //Transformer Modifier
    fun Modifier.transformable(
        state: TransformableState,
        lockRotationOnZoomPan: Boolean = false,
        enabled: Boolean = true
    ) = composed(
        factory = {
            ...
            if (enabled) Modifier.pointerInput(Unit, block) else Modifier
        },
    )
    
    //Draggable Modifier
    internal fun Modifier.draggable(
        stateFactory: @Composable () -> PointerAwareDraggableState,
        canDrag: (PointerInputChange) -> Boolean,
        orientation: Orientation,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        startDragImmediately: () -> Boolean,
        onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
        onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
        reverseDirection: Boolean = false
    ): Modifier = composed(
    ) {
        ...
        Modifier.pointerInput(orientation, enabled, reverseDirection) {
           ...
        }
    }
    
    

    通过 PointerInput Modifier 实现我们可以看出,我们所定义的自定义手势处理流程均发生在 PointerInputScope 中,suspend 关键字也告知我们自定义手势处理流程是发生在协程中。这其实是无可厚非的,在探索重组工作原理的过程中我们也经常能够看到协程的身影。

    fun Modifier.pointerInput(
        key1: Any?,
        block: suspend PointerInputScope.() -> Unit
    ): Modifier = composed(
        inspectorInfo = debugInspectorInfo {
            name = "pointerInput"
            properties["key1"] = key1
            properties["block"] = block
        }
    ) {
        val density = LocalDensity.current
        val viewConfiguration = LocalViewConfiguration.current
        remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
            val filter = this
            LaunchedEffect(this, key1) {
                filter.coroutineScope = this
                block()
            }
        }
    }
    

    接下来我们重点看看 PointerInputScope作用域,本文将着重解释一下部分我们用到的API,有想了解更全的可以移步大神文章:# 使用Jetpack Compose完成自定义手势处理

    点击类型基础 API

    API介绍

    API名称 作用
    detectTapGestures 监听点击手势

    我们知道,Clickable Modifier是compose给我们提供的单击事件,
    Clickable Modifier 不同的是,detectTapGestures 可以监听更多的点击事件。作为手机监听的基础 API,必然不会存在 Clickable Modifier 所拓展的涟漪效果。

    detectTapGestures包括四个函数回调,分别为:

    • onDoubleTap (可选):双击时回调

    • onLongPress (可选):长按时回调

    • onPress (可选):按下时回调

    • onTap (可选):轻触时回调

    suspend fun PointerInputScope.detectTapGestures(
        onDoubleTap: ((Offset) -> Unit)? = null,
        onLongPress: ((Offset) -> Unit)? = null,
        onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
        onTap: ((Offset) -> Unit)? = null
    ) = coroutineScope {
        ...
    }
    

    💡 Tips

    onPress 普通按下事件

    onDoubleTap 前必定会先回调 2 次 Press

    onLongPress 前必定会先回调 1 次 Press(时间长)

    onTap 前必定会先回调 1 次 Press(时间短)

    例子如下:

    @Composable
    fun TapGestureSample() {
        var boxSize = 100.dp
        Box(
            Modifier.fillMaxSize()
        ) {
            Text(text = "detectTapGestures\t监听点击手势", fontSize = 30.sp)
    
            Text(
                text = "",
                fontSize = 16.sp,
                modifier = Modifier.align(Alignment.BottomCenter)
            )
    
            Box(
                Modifier
                    .size(boxSize)
                    .align(Alignment.Center)
                    .background(Color.Green)
                    .pointerInput(Unit) {
                        detectTapGestures(
                            onDoubleTap = { offset: Offset ->
                                //双击时回调
                                println("detectTapGestures obDoubleTap[双击时回调] offset:$offset")
                            },
                            onLongPress = { offset: Offset ->
                                //长按时回调
                                println("detectTapGestures onLongPress[长按时回调] offset:$offset")
                            },
                            onPress = { offset: Offset ->
                                //按下时回调
                                println("detectTapGestures onPress[按下时回调] offset:$offset")
                            },
                            onTap = { offset: Offset ->
                                //轻触时回调
                                println("detectTapGestures onTap[轻触时回调] offset:$offset")
                            }
                        )
                    }
            )
        }
    }
    

    将上述例子运行一下就明白了,此处就不录gif了。

    手势检测

    transformable 修饰符

    接下来我们通过rememberTransformableState检测用于平移、缩放和旋转的多点触控手势,我们可以使用transformable修饰符。此修饰符本身不会转换元素,只会检测手势。

    rememberTransformableState内部是通过协程作用域来事实检测触控手势改变的。

    例子如下:

    @Composable
    fun TransformableSample() {
        // set up all transformation states
        var scale by remember { mutableStateOf(1f) }
        var rotation by remember { mutableStateOf(0f) }
        var offset by remember { mutableStateOf(Offset.Zero) }
        val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
            scale *= zoomChange
            rotation += rotationChange
            offset += offsetChange
        }
        Box(
            Modifier
                // apply other transformations like rotation and zoom
                // on the pizza slice emoji
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y
                )
                // add transformable to listen to multitouch transformation events
                // after offset
                .transformable(state = state)
                .background(Color.Blue)
                .fillMaxSize()
        )
    }
    

    需要部分如下依赖

    def accompanist_version = "0.24.3-alpha"
    //compose的viewpager库
    implementation "com.google.accompanist:accompanist-pager:$accompanist_version"  
    
    //指示器
    implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
    
    //CoilImage是google推荐我们去使用的加载网络图片的开源库
    implementation "com.google.accompanist:accompanist-coil:0.13.0" 
    

    功能实现

    我们回到我们的项目中,
    如上图所示,我们拆分一下该功能的实现。

    • 1: 实现图片横向水平滚动HorizontalPager

    • 2: 底部的水平切换的指示器:HorizontalPagerIndicator

    • 3: 双击放大和缩小

    • 4: 双指缩放

    • 5: 图片如有放大,切换时放大图还原至原始大小

    HorizontalPager

    HorizontalPager 是其中一种布局,他将所有子项摆放在一条水平行上,允许用户在子项之间水平滑动。

    [图片上传失败...(image-2367d7-1651050770637)]

    /**
     * 界面状态变更
     */
    val pageState = rememberPagerState(initialPage = currentIndex)
    
    HorizontalPager(
        count = 图片数量,
        state = pageState, //图片状态
        contentPadding = PaddingValues(horizontal = 0.dp), //图片间的间距
        modifier = Modifier.fillMaxSize()
    ) { page ->
        println("ImageBrowserItem current page: $page")
        ImageBrowserItem(images[page], page, this)
    }
    

    如果你想跳转到某一个特定页面,你可以在 CoroutineScope 中选择使用
    rememberPagerState(initialPage = currentIndex)
    pagerState.scrollToPage(index)pagerState.animateScrollToPage(index) 选一即可。

    HorizontalPagerIndicator

    HorizontalPagerIndicator用来标识 HorizontalPagerVerticalPager 的水平布局指示器,表示当前活动页面和使用 Shape 绘制的总页面。需要通过pageState绑定。

    HorizontalPagerIndicator(
        pagerState = pageState, //需要通过pageState绑定
        activeColor = Color.White,
        inactiveColor = WeComposeTheme.colors.onBackground,
        modifier = Modifier
            .align(Alignment.BottomCenter)
            .padding(60.dp)
    )
    

    双击放大和缩小

    对于我们要实现的双击事件来说,当双击时获取到已经缩放的scale,,则将当前图片缩放至原始图的两倍,也就是双击放大两倍,再次双击还原到原图大小,并且偏移量Offset恢复到中心点位置。如下部分代码:

    ...
    Modifier.pointerInput(Unit) {
        detectTapGestures(
            onDoubleTap = {
                println("ImageBrowserItem detectTapGestures onDoubleTap offset: $it")
                scale = if (scale <= 1f) {
                    2f
                } else {
                    1f
                }
                offset = Offset.Zero
            },
            onTap = {
    
            }
        )
    

    双指缩放

    对于我们要实现的双指缩放来说,我们只需要处理缩放大小即可。当我们监听rememberTransformableState变换时,scale放大的5倍时就停止继续放大。

    如下部分代码:

    /**
     * 监听手势状态变换
     */
    var state =
        rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
            scale = (zoomChange * scale).coerceAtLeast(1f)
            scale = if (scale > 5f) {
                5f
            } else {
                scale
            }
            println("ImageBrowserItem detectTapGestures rememberTransformableState scale: $scale")
        })
        ...
    Modifier
        .transformable(state = state)
        .graphicsLayer{  //布局缩放、旋转、移动变换
            scaleX = scale
            scaleY = scale
            translationX = offset.x
            translationY = offset.y
        }
    

    切换恢复图片大小

    在 pager 组件的 content scope 中允许开发者很轻松地拿到 currentPagecurrentPageOffset 引用。可以使用这些值来计算效果。我们提供了 calculateCurrentOffsetForPage() 扩展函数去计算某一个特定页面的偏移量。

    例子如下:

    @OptIn(ExperimentalPagerApi::class)
    @Composable
    private fun Sample() {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text(stringResource(R.string.horiz_pager_with_transition_title)) },
                    backgroundColor = MaterialTheme.colors.surface,
                )
            },
            modifier = Modifier.fillMaxSize()
        ) { padding ->
            HorizontalPagerWithOffsetTransition(Modifier.padding(padding))
        }
    }
    
    @OptIn(ExperimentalPagerApi::class, ExperimentalCoilApi::class)
    @Composable
    fun HorizontalPagerWithOffsetTransition(modifier: Modifier = Modifier) {
        HorizontalPager(
            count = 10,
            // Add 32.dp horizontal padding to 'center' the pages
            contentPadding = PaddingValues(horizontal = 32.dp),
            modifier = modifier.fillMaxSize()
        ) { page ->
            Card(
                Modifier
                    .graphicsLayer {
                        // Calculate the absolute offset for the current page from the
                        // scroll position. We use the absolute value which allows us to mirror
                        // any effects for both directions
                        val pageOffset = calculateCurrentOffsetForPage(page).absoluteValue
    
                        // We animate the scaleX + scaleY, between 85% and 100%
                        lerp(
                            start = 0.85f,
                            stop = 1f,
                            fraction = 1f - pageOffset.coerceIn(0f, 1f)
                        ).also { scale ->
                            scaleX = scale
                            scaleY = scale
                        }
    
                        // We animate the alpha, between 50% and 100%
                        alpha = lerp(
                            start = 0.5f,
                            stop = 1f,
                            fraction = 1f - pageOffset.coerceIn(0f, 1f)
                        )
                    }
                    .fillMaxWidth()
                    .aspectRatio(1f)
            ) {
                Box {
                    Image(
                        painter = rememberImagePainter(
                            data = rememberRandomSampleImageUrl(width = 600),
                        ),
                        contentDescription = null,
                        modifier = Modifier.fillMaxSize(),
                    )
    
                    ProfilePicture(
                        Modifier
                            .align(Alignment.BottomCenter)
                            .padding(16.dp)
                            // We add an offset lambda, to apply a light parallax effect
                            .offset {
                                // Calculate the offset for the current page from the
                                // scroll position
                                val pageOffset =
                                    this@HorizontalPager.calculateCurrentOffsetForPage(page)
                                // Then use it as a multiplier to apply an offset
                                IntOffset(
                                    x = (36.dp * pageOffset).roundToPx(),
                                    y = 0
                                )
                            }
                    )
                }
            }
        }
    }
    
    @OptIn(ExperimentalCoilApi::class)
    @Composable
    private fun ProfilePicture(modifier: Modifier = Modifier) {
        Card(
            modifier = modifier,
            shape = CircleShape,
            border = BorderStroke(4.dp, MaterialTheme.colors.surface)
        ) {
            Image(
                painter = rememberImagePainter(rememberRandomSampleImageUrl()),
                contentDescription = null,
                modifier = Modifier.size(72.dp),
            )
        }
    }
    

    我们可以在pager切换时通过calculateCurrentOffsetForPage(page).absoluteValue拿到当前pager的偏移量,当pageOffet == 1.0f 时证明pager已切换至下一页,此时我们恢复scale = 1f到原始大小即可,部分代码如下:

     Modifier
        .transformable(state = state)
        .graphicsLayer{  //布局缩放、旋转、移动变换
            scaleX = scale
            scaleY = scale
            translationX = offset.x
            translationY = offset.y
    
            val pageOffset = pagerScope.calculateCurrentOffsetForPage(page = page).absoluteValue
            if (pageOffset == 1.0f) {
                scale = 1.0f
            }
            println("ImageBrowserItem pagerScope calculateCurrentOffsetForPage pageOffset: $pageOffset")
        }
    

    到这里我们就整个实现了大图缩放、切换、预览功能,完整代码如下:

    import androidx.compose.foundation.Image
    import androidx.compose.foundation.gestures.*
    import androidx.compose.foundation.layout.*
    import androidx.compose.material.Surface
    import androidx.compose.material.swipeable
    import androidx.compose.runtime.*
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draw.rotate
    import androidx.compose.ui.geometry.Offset
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.graphics.graphicsLayer
    import androidx.compose.ui.input.pointer.pointerInput
    import androidx.compose.ui.layout.ContentScale
    import androidx.compose.ui.tooling.preview.Preview
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.viewmodel.compose.viewModel
    import com.eegets.wechatcompose.ui.find.model.ImageBrowserModel
    import com.eegets.wechatcompose.ui.theme.WeComposeTheme
    import com.google.accompanist.coil.rememberCoilPainter
    import com.google.accompanist.pager.*
    import kotlinx.coroutines.InternalCoroutinesApi
    import kotlinx.coroutines.flow.collect
    import kotlin.math.absoluteValue
    
    /**
     * 大图预览
     */
    @OptIn(ExperimentalPagerApi::class, InternalCoroutinesApi::class)
    @Composable
    fun ImageBrowserScreen(images: List<Image>, selectImage: Image) {
    
        var currentIndex = 0
    
        images.forEachIndexed { index, image ->
            if (image.url == selectImage.url) {
                currentIndex = index
                return@forEachIndexed
            }
        }
        /**
         * 界面状态变更
         */
        val pageState = rememberPagerState(initialPage = currentIndex)
    
        Box {
            HorizontalPager(
                count = images.size,
                state = pageState,
                contentPadding = PaddingValues(horizontal = 0.dp),
                modifier = Modifier.fillMaxSize()
            ) { page ->
                println("ImageBrowserItem current page: $page")
                ImageBrowserItem(images[page], page, this)
            }
    
            HorizontalPagerIndicator(
                pagerState = pageState,
                activeColor = Color.White,
                inactiveColor = WeComposeTheme.colors.onBackground,
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(60.dp)
            )
    
            LaunchedEffect(pageState) {
                snapshotFlow { pageState }.collect { pageState ->
                    println("ImageBrowserItem LaunchedEffect pageState currentPageOffset: $pageState.currentPageOffset")
                }
            }
        }
    }
    
    @OptIn(ExperimentalPagerApi::class)
    @Composable
    fun ImageBrowserItem(image: Image, page: Int = 0,  pagerScope: PagerScope) {
        /**
         * 缩放比例
         */
        var scale by remember { mutableStateOf(1f) }
    
        /**
         * 偏移量
         */
        var offset  by remember { mutableStateOf(Offset.Zero) }
    
        /**
         * 监听手势状态变换
         */
        var state =
            rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
                scale = (zoomChange * scale).coerceAtLeast(1f)
                scale = if (scale > 5f) {
                    5f
                } else {
                    scale
                }
                println("ImageBrowserItem detectTapGestures rememberTransformableState scale: $scale")
            })
    
        Surface(
            modifier = Modifier
                .fillMaxSize(),
            color = Color.Black,
        ) {
            Image(
                painter = rememberCoilPainter(
                    request = image.url
                ),
                contentDescription = "",
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .transformable(state = state)
                    .graphicsLayer{  //布局缩放、旋转、移动变换
                        scaleX = scale
                        scaleY = scale
                        translationX = offset.x
                        translationY = offset.y
    
                        val pageOffset = pagerScope.calculateCurrentOffsetForPage(page = page).absoluteValue
                        if (pageOffset == 1.0f) {
                            scale = 1.0f
                        }
                        println("ImageBrowserItem pagerScope calculateCurrentOffsetForPage pageOffset: $pageOffset")
                    }
                    .pointerInput(Unit) {
                        detectTapGestures(
                            onDoubleTap = {
                                println("ImageBrowserItem detectTapGestures onDoubleTap offset: $it")
                                scale = if (scale <= 1f) {
                                    2f
                                } else {
                                    1f
                                }
                                offset = Offset.Zero
                            },
                            onTap = {
    
                            }
                        )
                    }
            )
        }
    }
    
    @Preview
    @Composable
    fun ImageBrowserScreenPreview() {
        ImageBrowserScreen(
            images = mutableListOf(),
            selectImage = Image(url = "https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9bxk27j63do52iu0y02.jpg")
        )
    }
    

    调用

    val images = mutableListOf(
        Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha93zif8j63gg56ob2c02.jpg"),
        Image("https://wx3.sinaimg.cn/orj360/001YqBPrly1h0ha99r0vej652i3dox6r02.jpg"),
        Image("https://wx2.sinaimg.cn/orj360/001YqBPrly1h0ha96rjbij63do52inpf02.jpg"),
        Image("https://wx1.sinaimg.cn/orj360/001YqBPrly1h0ha9hek4cj652i3do1l202.jpg"),
        Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9bxk27j63do52iu0y02.jpg"),
        Image("https://wx3.sinaimg.cn/orj360/001YqBPrly1h0ha9e50phj652u3dwhdv02.jpg"),
        Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha91e0jxj63gg56o7wk02.jpg"),
        Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9jfmouj635x4rgb2b02.jpg"),
        Image("https://wx1.sinaimg.cn/orj360/001YqBPrly1h0ha9lzkk3j656o3gge8402.jpg")
    )
    
    val selectImage = Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha93zif8j63gg56ob2c02.jpg")
    
    ImageBrowserScreen(images = images, selectImage = selectImage)
    

    该功能只是仿Jetpack Compose微信的小部分功能,所以暂无源码,所后期会将仿微信全部代码上传至Github。

    参考资料

    # 使用Jetpack Compose完成自定义手势处理

    # 多点触控:平移、缩放、旋转

    # Jetpack Navigation Compose Animation

    相关文章

      网友评论

          本文标题:Compose版来啦!高仿微信朋友圈大图缩放、切换、预览功能

          本文链接:https://www.haomeiwen.com/subject/padlyrtx.html