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

3,476 阅读5分钟

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

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

先上动图

9EFAAA3094A927EDC1AD8E58F3996A35_.gif

在实现上述功能时首先我们需要了解一下 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 是其中一种布局,他将所有子项摆放在一条水平行上,允许用户在子项之间水平滑动。

ccdf5c9e05ce4baa9e89de02bc43670a_tplv-k3u1fbpfcp-zoom-in-crop-mark_1304_0_0_0.awebp

/**
 * 界面状态变更
 */
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