Jetpack Compose | 低仿哔哩哔哩(二)|播放与互动| 搜索与启动页

接触Compose一段时间了,不用就忘。作为练手写了一个类似bilibili的小应用,模块包含:

  • 首页列表
  • 详情播放,评论点赞
  • 启动页
  • 登录注册
  • 嵌套滑动布局
  • 扫码识别

延误了一段时间,尽管不完善但是总算可以分享出来了。不足之处后面陆续更新。

上一篇文章中主要记录了登录注册页面和路由框架的实现 ↓
Jetpack Compose | 低仿哔哩哔哩(一)|尝试实现 登录注册| 首页骨架

效果图:

启动页.gif 搜索页.gif 个人中心.gif 详情页.gif

一、启动页的实现:

  1. 首先要定义页面路由,将初始路由startDestination定义为启动页SplashScreen.

  2. 然后在启动页加入动画等动效,期间可以做点别的事,比如请求广告图片,鉴权判断token是否存在,登录是否过期,如果需要重新登录就Navigation到LoginScreen,否则跳转到主页MainScreen.

  3. 这里我的启动页没有什么特别之处,只是添加了一些动效。

  • 在viewModel中维护一些状态变量,用来标记动画状态,这里做的比较简单,检查本地登录口令是否存在,不存在就需要重新登录:
class SplashViewModel : ViewModel() {

    var isEndTask by mutableStateOf(false)
    var checkingLoginState = mutableStateOf(false)
    var loginState by mutableStateOf(-1)

    // 检查登录
    private fun checkLogin() {
        checkingLoginState.value = true
        viewModelScope.launch {

            // 查询登录令牌是否已保存来模拟判断是否已经登录
            flow {
                delay(5000) // 单纯为了动画执行做了延迟
                val boardingPass = DataStoreUtil.readStringData(BOARDING_PASS)
                val result = if (boardingPass.isBlank()) 0 else 1
                emit(result)
            }.flowOn(Dispatchers.IO).collect {
                Log.d("splash--:", "loginState:$it")
                loginState = it
                checkingLoginState.value = false
            }
        }
    }

    fun checkLoginState() {
        viewModelScope.launch {
            delay(1000)
            checkLogin()
            isEndTask = true
        }
    }
}
复制代码
  • 启动页除了动画之外,没有特别的功能
/**
* 启动页入口
* @param navNexEvent 下一步动作回调方法,跳转登录或者主页
*/
@ExperimentalAnimationApi
@Composable
fun SplashScreen(
    splashViewModel: SplashViewModel,
    navNexEvent: (Boolean) -> Unit
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .navigationBarsPadding()
            .background(MaterialTheme.colors.background),
    ) {
        splashViewModel.checkLoginState()

        // 大背景图片
        SplashBgOne()

        Column(
            modifier = Modifier
                .animateContentSize()
                .wrapContentSize()
                .align(Alignment.BottomCenter),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Row(
                modifier = Modifier
                    .wrapContentHeight(),
                verticalAlignment = Alignment.CenterVertically
            ) {
                // coil加载小人儿图片
                CoilImage(
                    modifier = Modifier
                        .width(50.dp)
                        .height(115.dp),
                    url = R.drawable.bili_man
                )
                Column(
                    modifier = Modifier.background(
                        // 此处是一个线性渐变背景
                        brush = Brush.verticalGradient(
                            colorStops = arrayOf(
                                0.0f to Color.White,
                                0.2f to bili_20,
                                0.5f to Color.Transparent,
                                0.7f to Color.White,
                                0.9f to Color.Transparent
                            )
                        )
                    )
                ) {
                    Text(
                        modifier = Modifier.padding(start = 5.dp),
                        text = "Bili...",
                        fontSize = 35.sp,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colors.onBackground
                    )
                    Text(
                        modifier = Modifier.padding(start = 5.dp, bottom = 6.dp),
                        text = "https://www.bilibili.com",
                        fontSize = 20.sp,
                        color = Color.Gray.copy(alpha = 0.7f),
                        fontStyle = FontStyle.Italic
                    )
                }
            }
            Spacer(modifier = Modifier.height(28.dp))

            // 被AnimatedVisibility包裹的内容,会根据设置的visible条件展示/隐藏
            AnimatedVisibility(
                visible = splashViewModel.checkingLoginState.value,
                modifier = Modifier
                    .padding(top = 20.dp)

            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    LinearProgressIndicator(
                        modifier = Modifier.width(150.dp)
                    )
                    Spacer(modifier = Modifier.height(20.dp))
                    Text(text = "检查登录状态...")
                    Spacer(modifier = Modifier.height(20.dp))
                }
            }
        }
    }
    // LaunchedEffect启动一个协程作用域
    LaunchedEffect(
        splashViewModel.loginState,
        splashViewModel.checkingLoginState
    ) {
        if (splashViewModel.loginState != -1 && !splashViewModel.checkingLoginState.value) {
            // 处理下一步
            navNexEvent(splashViewModel.loginState == 1)
        }
    }
}

@ExperimentalAnimationApi
@Composable
fun SplashBgOne() {
    // 先来创建三个动画空间
    val alphaAnim = remember {
        Animatable(0f)
    }
    val cornerAnim = remember {
        Animatable(50f)
    }
    val scaleAnim = remember {
        Animatable(0.2f)
    }

    
    LaunchedEffect(key1 = true) {
        // 给大背景加入渐出动画  
        alphaAnim.animateTo(targetValue = 1.0f,              
            animationSpec = tween(
                durationMillis = 500,
                easing = {
                    OvershootInterpolator(0.8f).getInterpolation(it)
                }
            )
        )
        // 给大背景加入圆角动画  
        cornerAnim.animateTo(targetValue = 1.0f,
            animationSpec = tween(
                durationMillis = 500,
                easing = {
                    OvershootInterpolator(0.8f).getInterpolation(it)
                }
            )
        )
        // 给大背景加入缩放动画
        scaleAnim.animateTo(targetValue = 1.0f,
            animationSpec = tween(
                durationMillis = 300,
                easing = {
                    OvershootInterpolator(0.6f).getInterpolation(it)
                }
            )
        )
    }
    // 给图片加上属性,值来源于对应的动画值
    Image(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxSize()
            .scale(scaleAnim.value)
            .alpha(alpha = alphaAnim.value)
            .clip(shape = RoundedCornerShape(1.dp.times(cornerAnim.value.toInt()))),
        //.align(Alignment.Center),
        painter = painterResource(id = R.drawable.splash_pic),
        contentScale = ContentScale.FillBounds,
        contentDescription = null
    )
}

复制代码

二、个人中心界面滑动动效

​ 页面滑动嵌套,官方提供了一个Modifier即nestedScroll()来处理滑动,通过它可以监听滑动进度和滑动方向。Google去年就有计划完善Compose的文档,最新的文档应该更加完善了,官方还提供了Samples:

nested.jpg

​ 简单用法如下,available保存了滑动的单步距离,通过测试日志发现了该变量的值与滑动方向的关系:
复制代码
// 创建与嵌套滚动系统的连接nestedScrollConnection,监听LazyColumn 中的滚动
val nestedScrollConnection = remember {
            object : NestedScrollConnection {
                override fun onPreScroll(
                    available: Offset,
                    source: NestedScrollSource
                ): Offset {
                    val delta = available.y
                    oLog("scroll: delta:$delta")
                    // 累加LazyColumn滑动距离
                    val newOffset = slideOffsetHeightPx.value + delta
                    slideOffsetHeightPx.value = newOffset.coerceIn(-maxUpPx, minUpPx)
                    return Offset.Zero
                }
            }
}
// 父滑动列表
Box(
    Modifier
   .fillMaxSize()
   .nestedScroll(nestedScrollConnection)
   ) {
            ...// 列表布局,比如LazyColumn
}
复制代码

数值与滑动方向的关系.jpg

然后,知道了每一步的距离,就可以得到累加滑动距离,还可以算出实时滑动进度,

然后根据进度与某部件的最大滑动距离,即可实时偏移部件位置或尺寸:

页面滑动示意.jpg

基于此,简单封装了一下,根据滑动进度调整控件位置,而调整控件位置这里是通过给它动态设置offset偏移量来达到的。

封装位于项目中的文件:

NestedWrapCustomLayout.kt 整个Header部分

ScrollableAppBar.kt Header中的横条信息栏

使用:

/**
* 个人中心
*/
@Composable
fun ProfileContentScreen(
    coroutineScope: CoroutineScope,
    viewModel: ProfileViewModel,
    columnLazyState: LazyListState,
    profileData: DataProfile
) {
    Scaffold(
        modifier = Modifier
            .fillMaxSize()
    ) {
        var isShowDialog by remember { mutableStateOf(false) }

        val navController = LocalNavController.current
        // 封装的滑动布局
        NestedWrapCustomLayout(
            columnTop = 202.dp,
            navigationIconSize = 80.dp,
            toolBarHeight = 56.dp,
            scrollableAppBarHeight = 202.dp,
            columnState = columnLazyState,
            scrollableAppBarBgColor = Color.LightGray,
            toolBar = { ProfileToolBar(profileData = profileData) },
            navigationIcon = { UserAdvertImg(advert = profileData.face) }, //默认为返回图标
            extendUsrInfo = { UserNameUI(profileData = profileData) },
            headerTop = { HeaderTop() },
            backSlideProgress = { progress ->

            }
        ) {
            // 这里有滑动布局空间LazyListScope,所以可以直接给封装布局中的LazyColumn添加item

            /** 1. 广告banner */
            item {
                BiliBanner(
                    modifier = Modifier
                        .fillMaxWidth()
                        .advancedShadow(
                            color = gray200,
                            alpha = 0.8f,
                            shadowBlurRadius = 10.dp,
                            offsetX = 2.dp,
                            offsetY = 3.dp
                        ),
                    items = viewModel.bannerDataList,
                    config = BannerConfig(
                        indicatorColor = Color.White.copy(0.8f),
                        selectedColor = bili_50.copy(0.8f),
                        intervalTime = 3000
                    ),

                    itemOnClick = { banner ->
                        // 点击banner
                        coroutineScope.launch {
                            NavUtil.doPageNavigationTo(
                                navController,
                                PageRoute.WEB_VIEW_ROUTE.replaceAfter("=", banner.url)
                            )
                        }
                    }
                )
            }

            /** 2. 广告课程 */
            stickyHeader {
                ColumnStickHeader(title = "你的广告", subTitle = "这些都是flutter课程广告")
            }
            profileData.courseList?.let {
                item {
                    CourseListView(courseList = it)
                }
            }
            // ...其他项目
        }
    }
复制代码

三、自定义二维码扫码界面动效

​ 扫码这里我用到了zxing和camera,先引入依赖库:

    // Camera
    implementation "androidx.camera:camera-camera2:1.0.2"
    implementation "androidx.camera:camera-lifecycle:1.0.2"
    implementation "androidx.camera:camera-view:1.0.0-alpha31"
    // Zxing
    implementation 'com.google.zxing:core:3.4.1'
复制代码
  •  首先需要申请相机权限,其次定义一个图像解析器,继承camera库的ImageAnalysis.Analyzer,重写其方法analyze();
    
     其中PlanarYUVLuminanceSource 优化了相机驱动返回的YUV数据,主要是去除冗余数据达到加速解码。
    复制代码
class QrCodeAnalyzer(
    private val onQrCodeScanned: (String) -> Unit
) : ImageAnalysis.Analyzer {

    private val supportedImageFormats = listOf(
        ImageFormat.YUV_420_888,
        ImageFormat.YUV_422_888,
        ImageFormat.YUV_444_888,
    )

    override fun analyze(image: ImageProxy) {
        if (image.format in supportedImageFormats) {
            val bytes = image.planes.first().buffer.toByteArray()

            val source = PlanarYUVLuminanceSource(
                bytes,
                image.width,
                image.height,
                0,
                0,
                image.width,
                image.height,
                false
            )
            val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
            try {
                val result = MultiFormatReader().apply {
                    setHints(
                        mapOf(
                            DecodeHintType.POSSIBLE_FORMATS to arrayListOf(
                                BarcodeFormat.QR_CODE
                            )
                        )
                    )
                }.decode(binaryBitmap)
                // 为了回调结果
                onQrCodeScanned(result.text)
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                image.close()
            }
        }
    }

    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()
        return ByteArray(remaining()).also {
            get(it)
        }
    }
}
复制代码
  • 在Compose中使用zxing扫描,这里涉及Compose与View体系的交互,Compose中
    使用View要用到AndroidView(),它自己本身也是一个Compose:
    复制代码
      AndroidView(
                modifier = Modifier.fillMaxSize(),
                factory = { context ->
                    val previewView = PreviewView(context)
                    val preview = Preview.Builder().build()
                    val selector = CameraSelector.Builder()
                        .requireLensFacing(CameraSelector.LENS_FACING_BACK)
                        .build()
                    preview.setSurfaceProvider(previewView.surfaceProvider)
                    val imageAnalysis = ImageAnalysis.Builder()
                        .setTargetResolution(
                            Size(previewView.width, previewView.height)
                        )
                        .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST).build()
                    imageAnalysis.setAnalyzer(
                        ContextCompat.getMainExecutor(context),
                        QrCodeAnalyzer { result ->
                            // 得到解析结果            
                            code = result
                        }
                    )

                    try {
                        cameraProviderFuture.get().bindToLifecycle(
                            lifecycleOwner,
                            selector,
                            preview,
                            imageAnalysis
                        )
                    } catch (e: Exception) {
                        e.printStackTrace()
                    }
                    previewView
                },
            )
复制代码

扫描动效界面也是一个加入了动画的Composable,将背景设置为透明,置于相机之上,监听zxing图像解析结果。

四、搜索页面注意点

  • 搜索页面主要涉及输入框自动获焦,页面状态管理。
    复制代码
  1. 首先定义键盘管理器,焦点请求处理器:
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = FocusRequester()
复制代码
  1. 其次,Modifier设置自动焦点请求,传入焦点请求处理器,然后onFocusChanged{}监听焦点变化,如果获焦则展示软键盘。
 TextField(
       modifier = Modifier
                .focusRequester(focusRequester)
                .onFocusChanged {
                    if (it.isFocused) {
                       keyboardController?.show()
                    }
                 }
                 .wrapContentHeight()
                 .width(210.dp),
        value = inputValue,
        // 监听输入变化,主动调用搜索
        onValueChange = {
            inputValue = it
            onSearch(inputValue)
        },
       // .....
     )
复制代码
  1. 最后,输入完成时触发隐藏软键盘:
keyboardActions = KeyboardActions(onSearch = {
      onSearch(inputValue)
      keyboardController?.hide()
}),
复制代码

综上,搜索顶部输入框的整体处理代码如下:

/**
 * 搜索输入框
 * */
@ExperimentalComposeUiApi
@Composable
fun SearchTopBar(
    needInputNow: String,
    onSearch: (String) -> Unit,
    onCancel: () -> Unit,
    onClearInput: () -> Unit
) {
    var inputValue by remember { mutableStateOf("") }

    if (needInputNow.isNotEmpty()) {
        inputValue = needInputNow
    }

    Row(
        modifier = Modifier
            .padding(horizontal = 16.dp)
            .fillMaxWidth()
            .height(48.dp)
            .background(color = Color.White, shape = RectangleShape),
        verticalAlignment = Alignment.CenterVertically
    ) {
        // 用来管理软键盘
        val keyboardController = LocalSoftwareKeyboardController.current
        val focusRequester = FocusRequester()

        Row(
            modifier = Modifier
                .width(320.dp)
                .background(color = gray200, shape = RoundedCornerShape(24.dp))
                .padding(end = 8.dp, start = 16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                modifier = Modifier
                    .size(36.dp)
                    .padding(start = 8.dp),
                imageVector = Icons.Rounded.Search,
                contentDescription = "search",
                tint = Color.Gray
            )
            TextField(
                modifier = Modifier
                    .focusRequester(focusRequester)
                    .onFocusChanged {
                        if (it.isFocused) {
                            keyboardController?.show()
                        }
                    }
                    .wrapContentHeight()
                    .width(210.dp),
                value = inputValue,
                onValueChange = {
                    inputValue = it
                    onSearch(inputValue)
                },
                placeholder = {
                    Text(
                        text = "面粉社长",
                        style = TextStyle(fontSize = 14.sp)
                    )
                },
                colors = TextFieldDefaults.textFieldColors(
                    cursorColor = bili_50,
                    // 将指示器所有状态颜色都设置透明
                    focusedIndicatorColor = Color.Transparent,
                    disabledIndicatorColor = Color.Transparent,
                    unfocusedIndicatorColor = Color.Transparent,
                    backgroundColor = gray200
                ),
                // 完成时动作自定义处理
                keyboardActions = KeyboardActions(onSearch = {
                    onSearch(inputValue)
                    keyboardController?.hide()
                }),
                textStyle = TextStyle(color = gray400),
                // 会指定一个按钮,比如Search, Done,等等
                keyboardOptions = KeyboardOptions.Default.copy(
                    imeAction = ImeAction.Search
                )
            )
            // 这里设计为:如果有输入文字,展示清除按钮
            if (inputValue.isNotEmpty()) {
                IconButton(
                    modifier = Modifier
                        .padding(4.dp),
                    onClick = {
                        inputValue = ""
                        onClearInput()
                    },
                ) {
                    Icon(
                        modifier = Modifier
                            .size(28.dp)
                            .background(
                                color = gray300.copy(alpha = 0.3f),
                                shape = CircleShape
                            ),
                        imageVector = Icons.Rounded.Clear,
                        contentDescription = "clear",
                        tint = gray400
                    )
                }
            }
        }

        DisposableEffect(Unit) {
            focusRequester.requestFocus()
            onDispose { }
        }

        TextButton(
            modifier = Modifier
                .padding(start = 4.dp)
                .wrapContentSize(),
            onClick = { onCancel() },
            contentPadding = PaddingValues(5.dp),
            colors = ButtonDefaults.textButtonColors(contentColor = Color.White)
        ) {
            Text(text = "取消", fontSize = 18.sp, color = Color.Gray)
        }

    }
}
复制代码

五、视频播放

关于视频播放部分,之前有文章分享过Compose中如何进行视频播放。【参考链接】

另外,列表分页加载使用了Paging分页框架【参考链接】


代码结构:

  • 拆分比较随意,仅仅是处于方便查找,按功能划分小模块
    复制代码

代码结构.jpg

本文源码:

github- Android Compose实现bili视频播放

github- flutter实现bili视频播放

猜你喜欢

转载自juejin.im/post/7070007479530160136