# Jetpack Compose | 低仿哔哩哔哩(一)|尝试实现 登录注册| 首页骨架

自从第一次体验过Compose之后,作为Compose小白,愈发感兴趣,遂发水文一篇,请boss们多多指点。
复制代码

本文非常简单的一个页面UI,就是一些新控件 + Navigation ,之前看过一些Flutter实现的案例,所以最近就想到用Compose尝试一下,手感跟Flutter感觉差别不大。

Compose中看别人的文章都是一个Activity,其他的都是Compose控件,也就是 Activity + N * Screen ,这个Screen就类似Fragment,只不过更加灵活。

Compose也可以通过onActive,onPreCommit,onCommit和onDispose处理生命周期, 分别表示:

>  compose函数第一次被渲染到画面  -> onActive
> compose函数每次执行之前             -> onPreCommit
> compose函数每次执行                    ->onCommit
> 画面重新渲染,从画面上移除           ->onDispose
复制代码

一、登录注册页面:

大概用到以下控件:

1. 顶部 TopAppBar ,脚手架的一部分
/**
 * 顶部TopBar
 * Scafflod的 appBar 并不仅仅限于TopAppBar控件,号可以是其他任意或自定义的@Compose组件
 * 源码: topBar: @Composable () -> Unit = {},
 * */
@Composable
fun TopBarView(
    iconEvent: @Composable (() -> Unit)? = null,
    titleText: String,
    actionEvent: @Composable RowScope.() -> Unit = {},
) {
    TopAppBar(
        title = {
            Text(
                text = titleText,
                color = Color.Black
            )
        },
        navigationIcon = iconEvent,
        actions = actionEvent,
        // below line is use to give background color
        backgroundColor = Color.White,
        contentColor = Color.Black,
        elevation = 12.dp
    )
}

// 源码:
@Composable
fun TopAppBar(
    title: @Composable () -> Unit, // 标题,不限于文字,可以自定义
    modifier: Modifier = Modifier, // 修饰符,warpContent,matchParent,size,背景,pading等等
    navigationIcon: @Composable (() -> Unit)? = null, // 导航按键,可以是任意按钮,IconButton,TextButton。。。
    actions: @Composable RowScope.() -> Unit = {}, // 右导航,可以是任意按键
    backgroundColor: Color = MaterialTheme.colors.primarySurface, 
    contentColor: Color = contentColorFor(backgroundColor), // 内容颜色
    elevation: Dp = AppBarDefaults.TopAppBarElevation  // 投影高度
) 
复制代码
  • 密码的明文密文模式切换,

根据密码输入框获取焦点监听是否获取了焦点,并且【眼睛】按键被用户选中了,则输入框为密文样式:

// 源码
@Composable
fun TextField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    readOnly: Boolean = false,
    textStyle: TextStyle = LocalTextStyle.current,
    label: @Composable (() -> Unit)? = null,
    placeholder: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    isError: Boolean = false,
    // 输入框获取焦点后对这个属性进行设置
    /**
 	* Interface used for changing visual output of the input field.
    *
    * This interface can be used for changing visual output of the text in the input field.
    * For example, you can mask characters in password filed with asterisk with
    * [PasswordVisualTransformation].
    */
    visualTransformation: VisualTransformation = VisualTransformation.None, // 默认是明文,也就是用户名那种
    /// ...
    )
复制代码

Modifier有一个监听获焦的回调,一旦这里发现获得焦点,即可将输入框的visualTransformation 设置为 PasswordVisualTransformation():

       modifier = Modifier
            .fillMaxWidth()
            .onFocusChanged {
                // 只有输入框获取焦点了,且输入框类型为密码类型,才算真正获焦,这时候捂脸动画需要顶部捂眼睛动画执行
                viewModel.onFocusHide(it.isFocused && (type == "password" || type == "rePassword"))
            },
      
      // 密码输入类型设置
    val visualTransformation =
        // 用户点击遮掩密码,并且输入框是密码类型,则给输入框设置密文样式,否则就是明文
        if (!viewModel.showPwd && (type == "password" || type == "rePassword")) PasswordVisualTransformation() else VisualTransformation.None
复制代码

其中,showPwd, onFocusHide 是mutableStateOf(Boolean) 类型自己定义的变量,维护UI的状态。

2. 页简单的路由,Navigation:
// 导航
implementation "androidx.navigation:navigation-compose:2.4.0-alpha09"
复制代码

/**
 * 定义路由
 * */
object PageRoute {
    const val LOGIN_ROUTE = "login_route"
    const val REGISTER_ROUTE = "register_route"
    const val MAIN_ROUTE = "main_route"
}

/**
 * 将页面与路由关联
 * 首先要初始化一个PageNavController实例:
 * pageNavController = rememberNavController()
 
 * */
@ExperimentalPagerApi
@Composable
fun PageNavHost(mainActivity: MainActivity) {
    val navHostController = MainActivity.pageNavController!!
    val isLogined = false // 是否登录

    // 初始路由destination
    val initRoute = if (isLogined) PageRoute.MAIN_PAGE else PageRoute.LOGIN_ROUTE
    NavHost(navController = navHostController, startDestination = initRoute) {
        // 定义路由,注册页面,登录页面
        composable(route = PageRoute.LOGIN_ROUTE) {
            LoginPage(activity = mainActivity)
        }
        composable(route = PageRoute.REGISTER_ROUTE) {
            RegisterPage(activity = mainActivity)
        }
    }
}

/**
 * 页面跳转
 * */
fun doPageNavigationTo(route: String) {
    val navController = MainActivity.pageNavController!!
    navController.navigate(route) {
        launchSingleTop = false
        popUpTo(navController.graph.findStartDestination().id) {
            // 防止状态丢失
            saveState = true
        }
        // 恢复composeble的状态
        restoreState = true
    }
}

/**
 * 页面回退
 * */
fun doPageNavBack(route: String?) {
    val navController = MainActivity.pageNavController!!
    route?.let {
        navController.popBackStack(route = it, inclusive = false)
    } ?: navController.popBackStack()
}
复制代码

二、首页结构:

  • 底部导航定义在主页(mainScreen)底部,分别对应4个Compose Screen页面,默认选中第一个Screen(homeScreen)。

  • 首页homeScreen 顶部需要有搜索框, 栏目滑动列表,默认选中第一个栏目。

1. 底部导航栏:
  • mainScreen 主页代码:

可以看到脚手架Scaffold除了提供顶部appBar,还提供了底部导航buttomBar,这里给底部导航提供了BottomNavigationScreen,带参是:导航控制器 + 底部元素dataList.

/**
 * 首屏 主页面
 * */
fun MainPage() {
    // 底部导航对应页面
    val list = listOf(
        Screens.Home,
        Screens.Ranking,
        Screens.Favorite,
        Screens.Profile,
    )
    val navController = rememberNavController()

    Scaffold(bottomBar = {
        // 底部导航,实际上Compose 对bottomBar要求很灵活,跟appBar一样,可以传入任何@Compose
        BottomNavigationScreen(navController = navController, items = list)
    }) {
        // 底部导航路由,定义了导航联动
        BottomNavHost(navHostController = navController)
    }
}
复制代码
  • 底部导航栏

    /**
     * 底部导航栏
     * */
    @Composable
    fun BottomNavigationScreen(navController: NavController, items: List<Screens>) {
        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val destination = navBackStackEntry?.destination
    
        BottomNavigation(backgroundColor = Color.White,elevation = 12.dp) {
            items.forEach { screen ->
                // 底部的每一个选项
                BottomNavigationItem(
                    selected = destination?.route == screen.route,
                    // 点击响应跳转
                    onClick = {
                        navController.navigate(screen.route) {
                            launchSingleTop = true
                            popUpTo(navController.graph.findStartDestination().id) {
                                // 防止状态丢失
                                saveState = true
                            }
                            // 恢复Composable的状态
                            restoreState = true
                        }
                    },
                    icon = {
                        Icon(
                            painter = painterResource(id = screen.icons),
                            contentDescription = null
                        )
                    },
                    label = { Text(screen.title) },
                    alwaysShowLabel = true,
                    unselectedContentColor = gray400,
                    selectedContentColor = bili_90,
                )
    
            }
        }
    }
    复制代码
  • 定义底部导航信息,定义了文字,图标和路由地址:

    /**
     * 首页底部页面路由定义
     * */
    sealed class Screens(val title: String, val route: String, @DrawableRes val icons: Int) {
    
        object Home : Screens(title = "首页", route = "home_route", icons = R.drawable.round_home_24)
    
        object Ranking :
            Screens(title = "排行", route = "ranking_route", icons = R.drawable.round_filter_24)
    
        object Favorite :
            Screens(title = "收藏", route = "fav_route", icons = R.drawable.round_favorite_24)
    
        object Profile :
            Screens(title = "我的", route = "profile_route", icons = R.drawable.round_person_24)
    }
    复制代码
  • 定义底部导航栏对应页面的路由

    这里跟登录跳转的路由一样,只不过这里的路由地址是定义在密封类 Screens里面的,这样做也是推荐做法,为了方便管理底部选项与页面的联动。

    /**
     * 将Home设为默认页面
     * */
    @Composable
    fun BottomNavHost(navHostController: NavHostController) {
        NavHost(navController = navHostController, startDestination = Screens.Home.route) {
            composable(route = Screens.Home.route) {
                HomeTabPage()
            }
            composable(route = Screens.Ranking.route) {
                RankingPage()
            }
            composable(route = Screens.Favorite.route) {
                FavoritePage()
            }
            composable(route = Screens.Profile.route) {
                ProfilePage()
            }
        }
    }
    复制代码
2. 顶部滑动列表:
  1. 主要是根据滑动列表对ViewPager动态滚动,反过来ViewPage滚动也要联动到滑动列表。

  2. Compose提供了与ViewPager一样的组件,即Pager,分为HorizontalPager和VerticalPager, 这里需要引入Pager:

关于Pager的了解可以看谷歌的文档,推荐一个比较好的用Pager实现banner轮播案例,来自大佬朱江的Compose Banner

 // 类似ViewPager
    implementation "com.google.accompanist:accompanist-pager:$accompanist_version"
复制代码
  1. 滑动tabView组件, 在 androidx.compose.material 下提供了两中tab样式,可滑动的和不可滑动的:
  • TabView: 不可滚动,子元素等宽,排列装不下的时候文字方向会由横向变纵向。适合以下场景:

  • ScrollableTabRow 可以滚动,子项长度取决包裹内容。

// >源码
@Composable
fun TabRow(
    selectedTabIndex: Int,  // 选中的下标
    modifier: Modifier = Modifier,
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    // 指示器默认是铺满内容的,此处用默认指示器
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
        TabRowDefaults.Indicator(
            Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
        )
    },
    divider: @Composable () -> Unit = @Composable {
        TabRowDefaults.Divider()
    },
    // 内容为Tab数组
    tabs: @Composable () -> Unit
)

// ScrollableTabRow 与TabRow参数差不多,只是多了两个参数:
// 首位间距pading
edgePadding: Dp = TabRowDefaults.ScrollableTabRowPadding,
// 底部与相邻内容的分割线
divider: @Composable () -> Unit = @Composable {
        TabRowDefaults.Divider()
    },
复制代码
  1. 滑动TabView的使用:
     val items = listOf("推荐", "电影", "电视剧", "综艺", "纪录片", "娱乐", "新闻")

    val tabstate = remember {
        mutableStateOf(items[0])
    }

	val pagerState = rememberPagerState(
        //pageCount = items.size, //总页数
        //initialOffscreenLimit = 3, //预加载的个数
        //infiniteLoop = false, //是否无限循环
        initialPage = 0 //初始页面
    )

   // 可滑动TabView
    ScrollableTabRow(
        selectedTabIndex = items.indexOf(tabstr.value),
        modifier = Modifier.wrapContentWidth(),
        edgePadding = 16.dp,
        // 这里用默认指示器,可自定义
        indicator = { tabIndicator ->
            TabRowDefaults.Indicator(
                Modifier.tabIndicatorOffset(
                    tabIndicator[items.indexOf(
                        tabstate.value
                    )]
                ),
                color = Color.Cyan
            )
        },
        // 背景色,有了一列tab,会挡住此背景,首尾各露出edgePadding的长度
        backgroundColor = colorResource(id = R.color.purple_500),
        // 底部分割线,可缺省,目的是为了与下面正文隔离开来
        divider = {
            TabRowDefaults.Divider(color = Color.Gray)
        }
    ) {
        items.forEachIndexed { index, title ->
            val selected = index == items.indexOf(tabstr.value)
            Tab(
                modifier = Modifier.background(color = colorResource(id = R.color.purple_200)),
                text = { Text(title, color = Color.White) },
                selected = selected,
                selectedContentColor = colorResource(id = R.color.purple_500),
                onClick = {
                    // 这里tabView与pager关联
                    tabstate.value = items[index]
                    scope.launch {
                        // Pager的切换
                        pagerState.scrollToPage(index)
                    }
                }
            )
        }
    }

   // 横向Pager类似PagerView
    HorizontalPager(
        state = pageState,
        count = items.size,
        reverseLayout = false
    ) { indexPage ->
       // 以下是Pager的内容
        Column(
            Modifier
                .fillMaxSize()
                .background(Color.White),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            when (indexPage) {
                in 0..(items.size) -> Text(text = items[indexPage])

            }
        }
    }
复制代码
3. TabView指示器:

​ *@param indicator表示当前选择哪个TAB的指示器。在默认情况下是一个[TabRowDefaults.Indicator],使用[TabRowDefaults.tabIndicatorOffset]

​ * 修饰符来确定它的位置。注意此指示符将被强制填满整个TabRow,所以你应该使用[TabRowDefaults.tabIndicatorOffset]或类似的来修改偏移量

可以看到,tabIndicatorOffset里面主要处理指示器的动画

// >源码
fun Modifier.tabIndicatorOffset(
        currentTabPosition: TabPosition
    ): Modifier = composed(
        inspectorInfo = debugInspectorInfo {
            name = "tabIndicatorOffset"
            value = currentTabPosition
        }
    ) {
        val currentTabWidth by animateDpAsState(
            targetValue = currentTabPosition.width,
            animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
        )
        // animateDpAsState 是动画包装,随着动画进度,会返回一个变化的值,感觉有点像估值器
        val indicatorOffset by animateDpAsState(
            targetValue = currentTabPosition.left,
            animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
        )
        fillMaxWidth()
            .wrapContentSize(Alignment.BottomStart)
            .offset(x = indicatorOffset) // 动画移动的偏移量
            .width(currentTabWidth)
}
复制代码
  • 自定义指示器:

自定义指示器可以给指示器添加需要的动画,样式和颜色。 这里我想定义一个可以跟着tab变化滑动的指示器。

要求:

  1. 可以带圆角 可设置高度宽度。
  2. 指示器有一个收缩效果,类似虫子蠕动效果,可以让延伸方向延伸速度快一点,收缩方向慢一点。
// 1.怎么画指示器? 可以用draw画一条线或者图像,也可以modifier设置一个backaground,这里shape用了圆角矩形

@Composable
fun BiliIndicator(
    height: Dp = TabRowDefaults.IndicatorHeight,
    color: Color = bili_50,
    modifier: Modifier = Modifier
) {
    Box(
        modifier
        	// 每一个指示器的子项的大小位置约束,这样不至于充满整个Tab
            .padding(top = 5.dp,bottom = 2.dp, start = 16.dp, end = 16.dp)
            .fillMaxWidth()
            .height(height)
            // 如果不用background,用border那就是一个框框
            .background(color = color,shape = RoundedCornerShape(size = 4.dp))
        
            // 跟上面一样的效果,想draw的话也可以自己draw:
            /*.drawWithContent(onDraw = {
                drawLine(
                    color = color,
                    strokeWidth = 8f,
                    start = Offset(0f, size.height),
                    end = Offset(size.width, size.height),
                    pathEffect = PathEffect.cornerPathEffect(radius = 5f)
                )
            })*/
    )
}

// 2. 指示器的动画定义,动画执行时会绘制上面的指示器样式,这里定义动画
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun BiliAnimatedIndicator(tabPositions: List<TabPosition>, selectedTabIndex: Int) {
    
    // 可以指定一些颜色,每个滑动子项取一个颜色,也可以用默认的就行了
    val colors = listOf(Color.Yellow, Color.Red, Color.Green)
    
    // transition 过渡的意思,用来处理动画管理动画,作用对象target是选中的tab即selectedTabIndex
    val transition = updateTransition(selectedTabIndex, label = "Transition")
    
    // 定义起始点,给距离做动画
    val indicatorStart by transition.animateDp(
        label = "Indicator Start",
        transitionSpec = {
            
            // 如果向右移动,则右边移动速度快
            // 如果向左移动,则左边速度快
            // spring提供了一个弹性空间,AnimatorSpace的一种,与补间动画tween,关键帧动画keyframes是类似的概念。
            // 阻尼比dampingRatio默认1f, 
           // 刚度:stiffness 刚度对立面是柔性,刚度越大弹簧伸缩速度越快,这里设小点,因为向右滑动,起点要慢点

            // 比较下标,目标>起始,说明是向右滑
            if (initialState < targetState) {
                spring(dampingRatio = 1f, stiffness = 50f)
            } else {
                // 左滑起点要快,刚度大
                spring(dampingRatio = 1f, stiffness = 1000f)
            }
        }
    ) {
        // TabPosition有left,right,width三个属性,描述的是你一个tab在整个tabView里面的位置和大小
        // 所以有 left + width = right
        tabPositions[it].left
    }

    // 定义终点
    val indicatorEnd by transition.animateDp(
        label = "Indicator End",
        transitionSpec = {
            if (initialState < targetState) {
                spring(dampingRatio = 1f, stiffness = 1000f)
            } else {
                spring(dampingRatio = 1f, stiffness = 50f)
            }
        }
    ) {
        tabPositions[it].right
    }

    // 可选颜色 列表colors指定的其中一个,也可以不指定
    val indicatorColor1 by transition.animateColor(label = "Indicator Color") {
        colors[it % colors.size]
    }

    //val indicatorColor2 = ColorUtil.getRandomColor(bili_50)

    // 绘制前面定义的指示器子项
    BiliIndicator2(
        // indicator当前颜色,指定颜色有默认值
        //color = indicatorColor1,
        height = 5.dp,
        modifier = Modifier
            // 填满整个TabView,并把指示器放在开始位置
            .fillMaxSize()
            .wrapContentSize(align = Alignment.BottomStart)
            // 设置偏移量定位指示器的开始位置
            .offset(x = indicatorStart)
            // 在选项卡之间移动时,指示器的宽度与动画宽度一致
            .width(indicatorEnd - indicatorStart)
    )
}
复制代码

画完了指示器,替代上面ScrollTabView中的 indicator = xxx, 就可以看到下面的效果:

猜你喜欢

转载自juejin.im/post/7036674714915471373