导航规整并实现登录页个人中心页
- 前言
- 导航规整
- 个人中心的实现
- MineViewmodel获取数据
- MinePage
- 请求头添加cookie
- 登录页面的实现
- OutlinedTextField 属性解析
- 封装输入框
- 输入框的使用
- 登录按钮实现
- 创建按钮状态枚举
- 定义transition
- 设置按钮颜色、大小以及shape
- 使用Button并配置样式
- 按钮全部代码
- LoginViewModel
- 源码地址
前言
在前面开发时只是注重了页面绘制,已经compose各种组件的使用,没有规整导航,所以页面跳转的操作很难实现;今天先规整一下页面导航,在页面跳转操作完成之后在绘制登录页以及个人中心页。
导航规整
在前面绘制页面的时候说到,compose打开页面的时候会在当前页面直击打开,所以就需要把要打开的页面都放在主页中进行打开,那么就要区分页面是首页,还是其他页面。
首先定义一个页面枚举,main代表首页,其他则是其他页面:
/**
* 页面类
* */
enum class RouteKey(val route:String){
Main("main"),
Login("login"),
WebView("webview")
}
使用navhost进行导航,主页所有页面都用navigation进行包裹:
@ExperimentalMaterialApi
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun RouteNavigation(navHostController: NavHostController,
onFinish: () -> Unit
){
val context = LocalContext.current
NavHost(navController = navHostController, startDestination = RouteKey.Main.route){
//主页面
navigation(
route = RouteKey.Main.route,
startDestination = Nav.BottomNavScreen.HomeScreen.route
){
composable(Nav.BottomNavScreen.HomeScreen.route){
HomePage(navHostController = navHostController)
}
....//其他要展示在主页面的paer
}
//要打开的新页面
//登录页
composable(RouteKey.Login.route) {
LoginPage(navHostController = navHostController)
}
}
}
首页导航页面把四个页面都封装起来:
object Nav {
sealed class BottomNavScreen(val route: String, @StringRes val resourceId: Int, @DrawableRes val id: Int) {
object HomeScreen: BottomNavScreen("home", R.string.nav_home, R.drawable.home_unselected)
object ProjectScreen: BottomNavScreen("project",R.string.nav_project,R.drawable.project_unselected)
object ClassicScreen: BottomNavScreen("classic",R.string.nav_classic,R.drawable.classic_unselected)
object MineScreen: BottomNavScreen("mine", R.string.nav_mine, R.drawable.mine_unselected)
}
//主页点击两次返回桌面
var onMainBackPressed = false
val bottomNavRoute = mutableStateOf<BottomNavScreen>(BottomNavScreen.HomeScreen)
}
将不同页面的展示封装到一个page,所有页面都在这个page打开,并加载到MainActivity里面去,但是要区分是主页,还是其他页面。
先判断是否是主页:
fun isMainScreen(route:String):Boolean = when(route){
Nav.BottomNavScreen.HomeScreen.route,
Nav.BottomNavScreen.ProjectScreen.route,
Nav.BottomNavScreen.ClassicScreen.route,
Nav.BottomNavScreen.MineScreen.route -> true
else -> false
}
然后根据得到的结果加载页面:
@ExperimentalPagerApi
@ExperimentalMaterialApi
@Composable
fun MainPage(
navHostController: NavHostController = rememberNavController(),
onFinish:() -> Unit
){
//返回back堆栈的顶部条目
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
//返回当前route
val currentRoute = navBackStackEntry?.destination?.route ?: Nav.BottomNavScreen.HomeScreen.route
//加载主页内容
if (isMainScreen(currentRoute)){
Scaffold(
contentColor = MaterialTheme.colors.background,
//标题栏
topBar = {
Column {
Spacer(
modifier = Modifier
.background(MaterialTheme.colors.primary)
.statusBarsHeight()
.fillMaxWidth()
)
}
},
//底部导航栏
bottomBar = {
Column {
BottomNavBar(Nav.bottomNavRoute.value, navHostController)
Spacer(
modifier = Modifier
.background(MaterialTheme.colors.primary)
.navigationBarsHeight()
.fillMaxWidth()
)
}
},
//内容
content = { paddingValues: PaddingValues ->
//内容嵌套在Scaffold中
RouteNavigation(navHostController, paddingValues, onFinish)
OnBackClick(navHostController)
})
}else{
//加载独立页面
RouteNavigation(navHostController, onFinish = onFinish)
}
}
到这里就完成了导航的规整,页面打开也没有问题,接下来就是个人中心页面以及登录页面的绘制和实现了。
个人中心的实现
目前个人中心比较简单,就展示了一个头像,昵称,用户id以及用户积分,更多的东西等到实现收藏等操作之后在添加,简单看一下效果图。
布局元素比较简单,这里就不贴布局文件了。
MineViewmodel获取数据
登录成功之后保存cookie,通过cookie调用用户信息接口,获取用户信息。
class MineViewModel : ViewModel() {
//默认头像
val defaultHead = "https://jusha-info.oss-cn-shenzhen.aliyuncs.com/obt/mall/upload/image/store/2021/08/06/1628250153533.png"
private val _userInfo = MutableLiveData<UserConfigModule>()
val userInfo = _userInfo
fun getUserInfo(){
Log.e("intoTAG","get user info")
NetWork.service.getUserInfo().enqueue(object : Callback<BaseResult<UserConfigModule>>{
override fun onResponse(
call: Call<BaseResult<UserConfigModule>>,response: Response<BaseResult<UserConfigModule>>) {
Log.e("intoTAG","response")
response.body()?.let {
_userInfo.value = it.data
}
}
override fun onFailure(call: Call<BaseResult<UserConfigModule>>, t: Throwable) {
Log.e("intoTAG","onFailure${t.message}")
}
})
}
}
MinePage
获取信息并展示。
@Composable
fun MinePage(navHostController: NavHostController){
val mineViewModel:MineViewModel = viewModel()
val userInfo by mineViewModel.userInfo.observeAsState()
mineViewModel.getUserInfo()
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())) {
com.yangchoi.composeuidemo.ui.bar.TopAppBar(title = "我的")
Box(
Modifier
.background(Color.White)
.fillMaxSize()
) {
Column(Modifier.fillMaxSize()) {
if (userInfo !== null){
//头像昵称
ConstraintLayout {
val (headImg,userName,userId) = createRefs()
Image(painter = rememberImagePainter(mineViewModel.defaultHead),
contentDescription = "用户头像",
modifier = Modifier
.size(80.dp)
.padding(16.dp, 20.dp, 0.dp, 0.dp)
.clip(shape = RoundedCornerShape(50))
.constrainAs(headImg) {})
Text(text = "${userInfo!!.userInfo.nickname}",fontSize = 14.sp,color = Color.Black,modifier = Modifier
.padding(10.dp, 20.dp, 0.dp, 0.dp)
.constrainAs(userName) {
start.linkTo(headImg.end)
top.linkTo(headImg.top)
})
Text(text = "${userInfo!!.userInfo.id}",fontSize = 12.sp,color = Color.Gray,modifier = Modifier
.padding(10.dp, 20.dp, 0.dp, 0.dp)
.constrainAs(userId) {
start.linkTo(headImg.end)
bottom.linkTo(headImg.bottom)
})
}
ConstraintLayout(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp)
.height(50.dp)) {
val (icons,title,integral,btmLine) = createRefs()
Row(Modifier
.constrainAs(icons) {}
.fillMaxHeight()
.padding(16.dp, 0.dp, 0.dp, 0.dp),
verticalAlignment = Alignment.CenterVertically) {
Image(painter = painterResource(id =R.drawable.icon_integral),
contentDescription = "积分", modifier = Modifier
.height(20.dp)
.width(20.dp))
}
Row(modifier = Modifier
.constrainAs(title) {
start.linkTo(icons.end)
}
.fillMaxHeight()
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(text = "积分",fontSize = 12.sp,color = Color.Black,textAlign = TextAlign.Center)
}
Row(modifier = Modifier
.fillMaxHeight()
.constrainAs(integral) {
end.linkTo(parent.end)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(text = "${userInfo!!.coinInfo.coinCount}",fontSize = 12.sp,color = Color.Gray,textAlign = TextAlign.Center,)
}
Divider(
modifier = Modifier
.padding(0.dp, 0.dp, 16.dp, 0.dp,)
.constrainAs(btmLine) {
bottom.linkTo(parent.bottom)
},
color = Color(229,224,227),
thickness = 1.dp,
startIndent = 16.dp)
}
}else{
Row(modifier = Modifier
.padding(horizontal = 16.dp, vertical = 200.dp)
.fillMaxWidth()
.height(50.dp)
.border(
1.dp,
color = Color(114, 160, 240),
shape = RoundedCornerShape(20.dp)
),verticalAlignment = Alignment.CenterVertically) {
Text(text = "登 录",
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.clickable {
navHostController.navigate("${RouteKey.Login.route}")
},
color = Color(114, 160, 240),
textAlign = TextAlign.Center)
}
}
}
}
}
}
请求头添加cookie
登录成功之后会返回一个cookie在请求头里面,只需要将cookie拦截并保存下来,就可以通过cookie去获取用户信息。
//创建OKhttp
private val client: OkHttpClient.Builder = OkHttpClient.Builder()
.addInterceptor(LogInterceptor())
.addInterceptor {
val request = it.request()
val response = it.proceed(request)
val requestUrl = request.url.toString()
val domain = request.url.host
//cookie可能有多个,都保存下来
if ((requestUrl.contains(SAVE_USER_LOGIN_KEY) || requestUrl.contains(SAVE_USER_REGISTER_KEY))) {
val cookies = response.headers(SET_COOKIE_KEY)
val cookie = encodeCookie(cookies)
saveCookie(requestUrl, domain, cookie)
}
response
}
//请求时设置cookie
.addInterceptor {
val request = it.request()
val builder = request.newBuilder()
val domain = request.url.host
//获取domain内的cookie
if (domain.isNotEmpty()) {
val sqDomain: String = DataStoreUtil.readStringData(domain, "")
val cookie: String = if (sqDomain.isNotEmpty()) sqDomain else ""
if (cookie.isNotEmpty()) {
builder.addHeader(COOKIE_NAME, cookie)
}
}
it.proceed(builder.build())
}
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
在网络请求的位置添加以上两个拦截器就行了。
登录页面的实现
首先来看效果图。
简单的绘制了一个登录页面,UI就不要纠结了,丑是真的丑~
可以看到在输入框左边有一个图标,然后是提示内容,以及密码框右边的显示和隐藏密码的图标;选中的时候颜色发生改变,并且在左上角显示提示用户输入的内容。
OutlinedTextField 属性解析
在实现以上效果前,先要了解OutlinedTextField的属性,才能加以运用 ;先看一下属性列表。
@Composable
fun OutlinedTextField(
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,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = MaterialTheme.shapes.small,
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors()
)
- 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 是否处于错误状态
- visualTransformation: VisualTransformation = VisualTransformation.None, 转换输入值的视觉表示
- keyboardOptions: KeyboardOptions = KeyboardOptions.Default 输入框输入类型
- singleLine: Boolean = false, 是否单行显示
- maxLines: Int = Int.MAX_VALUE 最大行数
- colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors() 颜色集合,设置获取焦点,失去焦点以及光标等颜色
大致就是这些属性了,知道使用之后就可以封装输入框了。
封装输入框
定义状态枚举PwdShowState,通过状态设置密码是否可见。
通过value:String设置输入框显示的值。
通过placeholder:String设置输入框提示的值。
通过color:TextFieldColors设置对应状态下的颜色,获得焦点、失去焦点、以及光标时候的颜色。
通过leadingIcon:ImageVector设置左侧图标。
通过trailingIcon:ImageVector设置右侧图标,通过trailingtintIcon:Color设置图标颜色。
通过keyboardOptions: KeyboardOptions设置输入框输入类型。
通过visualTransformation: VisualTransformation = VisualTransformation.None改变设置密码是否可见。
通过onValueChange:(String) -> Unit获取输入框发生改变时值的回调。
//输入框
enum class PwdShowState{
Show,Hide
}
@Composable
fun MyTextField(value:String,
label:String,
placeholder:String,
color:TextFieldColors,
leadingIcon:ImageVector,
trailingIcon:ImageVector,
trailingtintIcon:Color,
modifier: Modifier,
modifierTrailing: Modifier,
keyboardOptions: KeyboardOptions,
visualTransformation: VisualTransformation = VisualTransformation.None,
onValueChange:(String) -> Unit){
val showState = remember {
mutableStateOf(PwdShowState.Hide)
}
val icon = if (showState.value === PwdShowState.Hide){
painterResource(id = R.drawable.pwd_look)
}else{
painterResource(id = R.drawable.pwd_hide)
}
OutlinedTextField(value = value,
colors = color,
label = {
Text(text = label)
},
placeholder = {
Text(text = placeholder)
},
modifier = modifier,
keyboardOptions = keyboardOptions,
leadingIcon = {
Icon(leadingIcon,"左边图标",modifierTrailing,trailingtintIcon)
},
trailingIcon = {
if (label.equals("密码")){
IconButton(onClick = {
if (showState.value === PwdShowState.Hide){
showState.value = PwdShowState.Show
}else{
showState.value = PwdShowState.Hide
}
}) {
if (showState.value === PwdShowState.Hide){
Icon(icon, contentDescription = "点击密码可见",modifier = Modifier.size(30.dp))
}else{
Icon(icon, contentDescription = "点击密码隐藏",modifier = Modifier.size(30.dp))
}
}
}
},
visualTransformation = if (label.equals("密码")){
if (showState.value === PwdShowState.Hide){ PasswordVisualTransformation()} else visualTransformation
}else{
visualTransformation
},
singleLine = true,
onValueChange = onValueChange)
}
输入框的使用
根据不同的使用场景,设置不同的参数。
账号:
val userName = remember {
mutableStateOf("")
}
val colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(68,84,246),
unfocusedBorderColor = Color.Gray,
cursorColor = Color(68,84,246)
)
MyTextField(
value = userName.value,
label = "账号",
placeholder = "请输入账号",
color = colors,
leadingIcon = Icons.Default.Phone,
trailingIcon = Icons.Default.Phone,
trailingtintIcon = Color(68,84,246),
modifier = Modifier
.padding(12.dp, 0.dp, 12.dp, 0.dp)
.fillMaxWidth(),
modifierTrailing = Modifier,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
onValueChange = {
userName.value = it
}
)
密码:
val password = remember {
mutableStateOf("")
}
val colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(68,84,246),
unfocusedBorderColor = Color.Gray,
cursorColor = Color(68,84,246)
)
MyTextField(
value = password.value,
label = "密码",
placeholder = "请输入密码",
color = colors,
leadingIcon = Icons.Default.Lock,
trailingIcon = Icons.Default.Lock,
trailingtintIcon = Color(68,84,246),
modifier = Modifier
.padding(12.dp, 0.dp, 12.dp, 0.dp)
.fillMaxWidth(),
modifierTrailing = Modifier,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password
),
onValueChange = {
password.value = it
}
)
输入框实现完成~
登录按钮实现
在点击登录按钮的时候,登录接口请求过程中加载一个简单的动画,在登录成功或者失败之后结束动画。
创建按钮状态枚举
Normal正常情况下的状态
Pressed 按下时的状态
remember 记录状态的值
//按钮添加动画
enum class ButtonState{
Normal,Pressed
}
//记录状态值
val buttonState = remember {
mutableStateOf(ButtonState.Normal)
}
定义transition
定义一个transition,以及后面通过该元素设置颜色、大小等参数。
val transition = updateTransition(targetState = buttonState, label = "ButtonTransition")
设置按钮颜色、大小以及shape
val buttonBackgroundColor: Color by transition.animateColor(
transitionSpec = { tween(duration)}
) { buttonState ->
when(buttonState.value){
ButtonState.Normal -> Color(68,84,246)
ButtonState.Pressed -> Color(68,84,246)
}
}
val buttonWidth: Dp by transition.animateDp(transitionSpec = {
tween(duration)}
) {buttonState ->
when(buttonState.value){
ButtonState.Normal -> 300.dp
ButtonState.Pressed -> 60.dp
}
}
val buttonShape: Dp by transition.animateDp(transitionSpec = {
tween(duration)}
) {buttonState ->
when(buttonState.value){
ButtonState.Normal -> 4.dp
ButtonState.Pressed -> 100.dp
}
}
使用Button并配置样式
属性列表:
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
)
- onClick: () -> Unit 点击事件回调
- enabled: Boolean = true 是否可用,是否可以点击,这里可以加上判断,当用户名和密码都不为空的时候可以使用enabled = !userName.isNullOrBlank() && !password.isNullOrBlank()
- colors: ButtonColors = ButtonDefaults.buttonColors() 设置背景颜色以及点击时候的背景颜色等
- content: @Composable RowScope.() -> Unit compose函数,实现逻辑
Button(modifier = Modifier
.width(buttonWidth)
.height(50.dp),shape = RoundedCornerShape(buttonShape),
onClick = {
//todo
},colors = ButtonDefaults.buttonColors(
backgroundColor = buttonBackgroundColor,
disabledBackgroundColor = Color(68,84,246).copy(0.5f)
),enabled = !userName.isNullOrBlank() && !password.isNullOrBlank()) {
if (buttonState.value == ButtonState.Normal){
Text(text = "登录")
}else{
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
点击逻辑,将按钮状态设置成Pressed
buttonState.value = ButtonState.Pressed
并请求登录接口
loginViewModel.toLogin(userName,password,{
//回调 状态重置
buttonState.value = ButtonState.Normal
navHostController.navigateUp()
})
通过CircularProgressIndicator实现动画
if (buttonState.value == ButtonState.Normal){
Text(text = "登录")
}else{
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
按钮全部代码
按钮封装的代码:
//按钮添加动画
enum class ButtonState{
Normal,Pressed
}
@Composable
fun MyButton(userName:String,password:String,loginViewModel:LoginViewModel,navHostController: NavHostController){
val buttonState = remember {
mutableStateOf(ButtonState.Normal)
}
val transition = updateTransition(targetState = buttonState, label = "ButtonTransition")
val duration = 600
val buttonBackgroundColor: Color by transition.animateColor(
transitionSpec = { tween(duration)}
) { buttonState ->
when(buttonState.value){
ButtonState.Normal -> Color(68,84,246)
ButtonState.Pressed -> Color(68,84,246)
}
}
val buttonWidth: Dp by transition.animateDp(transitionSpec = {
tween(duration)}
) {buttonState ->
when(buttonState.value){
ButtonState.Normal -> 300.dp
ButtonState.Pressed -> 60.dp
}
}
val buttonShape: Dp by transition.animateDp(transitionSpec = {
tween(duration)}
) {buttonState ->
when(buttonState.value){
ButtonState.Normal -> 4.dp
ButtonState.Pressed -> 100.dp
}
}
Button(modifier = Modifier
.width(buttonWidth)
.height(50.dp),shape = RoundedCornerShape(buttonShape),onClick = {
buttonState.value = ButtonState.Pressed
loginViewModel.toLogin(userName,password,{
buttonState.value = ButtonState.Normal
navHostController.navigateUp()
})
},colors = ButtonDefaults.buttonColors(
backgroundColor = buttonBackgroundColor,
disabledBackgroundColor = Color(68,84,246).copy(0.5f)
),enabled = !userName.isNullOrBlank() && !password.isNullOrBlank()) {
if (buttonState.value == ButtonState.Normal){
Text(text = "登录")
}else{
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
}
调用:
MyButton(userName.value,password.value,loginViewModel,navHostController)
到这里呢整个登录页面所有的元素都构建好了,剩下的就是viewmodel实现登录请求以及结果回调了。
LoginViewModel
class LoginViewModel : ViewModel() {
private val _loginInfo = MutableLiveData<Any>()
val loginInfo = _loginInfo
fun toLogin(username:String,password:String,callback:()->Unit){
NetWork.service.login(username,password).enqueue(object : Callback<BaseResult<Any>>{
override fun onResponse(call: Call<BaseResult<Any>>,response: Response<BaseResult<Any>>) {
response.body()?.let {
_loginInfo.value = it
}
callback.invoke()
}
override fun onFailure(call: Call<BaseResult<Any>>, t: Throwable) {
callback.invoke()
}
})
}
}
登录页面的绘制以及实现就完成了,因为不能放置视频,登录按钮点击时的动画也没有弄成GIF,这里就不放效果图了,代码很简单,效果跑起来就能看到。
源码地址
gitee源码地址:戳我~