主页导航开发
书接上回
我们已经明确了我们的开发组件和目录结构
本文章我们从home目录设计和导航组件的设计切入
由此对jetpack有深层理解
结构
根据我们的三层结构(如下)我们可以对我们的项目的目录进行更改
app/
├── data/
│ ├── local/ # 本地数据源
│ │ ├── dao/ # Room DAO接口
│ │ ├── entity/ # 数据库实体
│ │ └── AppDatabase.kt
│ ├── repository/ # 仓库实现
│ └── model/ # 数据模型
├── domain/
│ ├── model/ # 领域模型
│ ├── repository/ # 仓库接口
│ └── usecase/ # 业务用例
├── presentation/
│ ├── component/ # 可复用Compose组件
│ ├── screen/ # 各功能屏幕
│ ├── theme/ # 应用主题
│ ├── navigation/ # 导航配置
│ └── viewmodel/ # ViewModel
└── di/ # 依赖注入配置
如以下结构,来满足我们的三层架构的设计
注意:UI层就是以上的presentation层
安卓的home界面一般包含两大部分,也就是上部分和下部分:上部分展示标题和导航图标,我们命名为topBar区
;下部分就是主要内容的展示,我们命名为mainBox区
。
对于安卓框架的基本开发,我们初步的计划是这样的:
-
topBar区
:在所有的安卓架构基本都是固定的,包含的要素就是:导航栏(或者返回键,本质也是一种导航),标题等。 -
mainBox区
:由于初步开发界面,我们可以整点简单的:放图片或者logo、把导航放在桌面、整个通用组件
那么我们的导航部分怎么开发呢?
还记得上次我们加入了导航的依赖navigation
我们就可以利用这个工具进行开发
我们再ui层新建一个navigation文件夹,我们把跟导航有关的文件存在里面
Jetpack Navigation 的 Kotlin DSL 设计(针对 Composable)
对于使用 Jetpack Compose 和 Navigation 的 Kotlin DSL 设计,可以完全摆脱 XML 导航图,直接在 Kotlin 代码中定义导航结构。是详细设计:
基本设置
首先确保我们的库build.gradle
里包含必要的依赖:
dependencies {
implementation("androidx.navigation:navigation-compose:2.7.7") // 使用最新版本
}
基础导航结构设计
package com.example.myapplication.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.myapplication.ui.screen.HomeScreen
import com.example.myapplication.ui.screen.OtherScreen
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home" // 起始目的地
) {
// 在这里定义所有可组合目的地
composable("home") {
HomeScreen(navController)
}
composable("other") {
OtherScreen(navController)
}
}
}
这段代码实现了一个基于 Jetpack Compose 的导航功能,用于在 Android 应用中管理不同屏幕之间的切换。以下是它的具体功能:
-
导航控制器:
- 使用
rememberNavController()
创建并记住一个导航控制器 (navController
),用于管理应用内的导航堆栈和屏幕切换。
- 使用
-
导航图 (NavHost):
- 通过
NavHost
组件定义应用的导航结构,它是所有可导航目的地的容器。 startDestination = "Home"
指定应用启动时显示的初始屏幕。
- 通过
-
定义可组合目的地:
- 使用
composable
函数定义了两个可导航的屏幕:"home"
路由:对应HomeScreen
,并传入navController
以便从该屏幕导航到其他屏幕。"other"
路由:对应OtherScreen
,同样传入navController
。
- 使用
-
功能总结:
- 应用启动时会显示
HomeScreen
(通过"main"
路由)。 - 在
HomeScreen
或OtherScreen
中,可以通过navController
的方法(如navigate("profile")
)切换到另一个屏幕。 - 支持返回导航(如系统返回按钮或编程方式返回)。
- 应用启动时会显示
补充说明:
导航逻辑:实际屏幕间的跳转需要在 HomeScreen
或 OtherScreen
中通过按钮等 UI 元素触发,例如调用 navController.navigate("other")
。
扩展性:可以继续在 NavHost
的 lambda 中添加更多 composable
条目来扩展更多屏幕。
参数传递:如果需要传递参数,可以使用 composable
的 arguments
参数(未在本代码中体现)。
PS:这是一个基础的导航框架,实际开发中可能会结合 ViewModel、路由对象等进一步封装。
那么他们直接怎么互相切换呢,怎么体现导航呢,那么就得借助页面的逻辑设计了(借助于各个组件的viewModel封装)
以下是个例子,具体的实现我们在后面的home设计介绍
// 在可组合函数中
Button(onClick = { navController.navigate("你的导航标签") }) {
Text("查看详情")
}
路由封装
将路由封装起来是一个很好的实践,尤其是当项目规模增大时,直接使用字符串常量(如 "main"
)容易导致以下问题:
- 难以维护:字符串硬编码容易拼写错误,且修改时需全局搜索。
- 缺乏关联信息:路由可能需要附带图标、标签(Label)、参数等额外信息。
- 类型不安全:字符串无法在编译时检查有效性。
改进方案:使用 sealed class/interface
封装路由
通过 sealed class
或 sealed interface
,可以将路由、图标、标签等统一管理,并提供类型安全的导航。以下是优化后的代码:
- 定义路由密封类
将每个路由封装为一个对象,并关联其图标和标签:
// 文件:Routes.kt
sealed class Route(
val route: String,
@StringRes val labelRes: Int,
@DrawableRes val iconRes: Int? = null // 可选图标
) {
// 主屏幕
object Home : Route(
route = "home",
labelRes = R.string.home_label,
iconRes = R.drawable.ic_home
)
// 个人资料页
object Profile : Route(
route = "other",
labelRes = R.string.profile_label,
iconRes = R.drawable.ic_profile
)
// 其他路由...
}
- 更新导航图
在 NavHost
中使用封装后的路由对象,避免硬编码字符串:
// 文件:AppNavigation.kt
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Route.Home.route // 使用密封类的路由
) {
composable(Route.Home.route) {
HomeScreen(navController)
}
composable(Route.Profile.route) {
ProfileScreen(navController)
}
}
}
- 在 BottomNavigation 中使用
那么我们的 TopBar区的 导航栏部分,可以直接复用 Route
中的图标和标签:
@Composable
fun TopBarNav(navController: NavController) {
val routes = listOf(Route.Home, Route.Profile) // 可导航的底部项
BottomNavigation {
val currentRoute = navController.currentDestination?.route
routes.forEach { route ->
BottomNavigationItem(
selected = currentRoute == route.route,
onClick = {
navController.navigate(route.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
},
icon = { Icon(painterResource(route.iconRes!!), contentDescription = null) },
label = { Text(stringResource(route.labelRes)) }
)
}
}
}
优势总结
- 类型安全:所有路由集中管理,避免拼写错误。
- 扩展性强:新增路由只需在
Route
密封类中添加一个对象。 - 关联信息:图标、标签与路由绑定,避免分散定义。
- 代码复用:可在
BottomNavigation
、Drawer
等组件中复用路由配置。
基于以上架构设计,我们就可以针对我们背单词系统设计很多导航:比如Home主页、Search搜索、Plan今日学习计划、ImageRec图片识别翻译、Note单词笔记等等把他们都封装在sealed interface内
主页开发
界面结构划分
根据我们的以上内容,我们有两个区域
-
topBar区
:在所有的安卓架构基本都是固定的,包含的要素就是:导航栏(或者返回键,本质也是一种导航),标题等。 -
mainBox区
:由于初步开发界面,我们可以整点简单的:放图片或者logo、把导航放在桌面、整个通用组件
目前通用组件,我想的一个好的组件就是日历组件,用于记录我们背单词的学习情况
那么这个home架构怎么设计?
我们可以把这两个区域封装在HomeContent里进行复用,那么content代码的设计结构如下
HomeContent
├── Column (全屏)
│ ├── TopBar()
│ └── Box (居中)
│ └── Column
│ ├── Spacer (占位)
│ ├── Image (动态图片)
│ ├── HomeActionButtons (导航按钮)
│ └── CalendarExample (日历通用组件)
那么有结构我们就可以设计界面:
@Composable
fun HomeScreen(
viewModel: HomeViewModel,
navigateTo: (String) -> Unit,
) {
HomeContent(
navigateTo = navigateTo,
)
}
@Composable
private fun HomeContent(
navigateTo: (String) -> Unit
) {
Column(
modifier = Modifier
.background(background)
.windowInsetsPadding(insets = WindowInsets.systemBars)
.fillMaxSize()
) {
TopBar()
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(16.dp)
.weight(1f)
.fillMaxWidth()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1.7f))
Box(
modifier = Modifier
.weight(3.5f)
.fillMaxWidth()
) {
Image(
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
// 封装好的按钮组件
HomeActionButtons(
navigateTo = navigateTo,
modifier = Modifier.padding(vertical = 16.dp)
)
CalendarExample()
}
}
}
}
界面代码解析
主页界面(HomeScreen),主要包含以下功能:
1. 整体布局
- 使用
Column
作为根布局,填充整个屏幕(fillMaxSize
)。 - 通过
Modifier.background
设置背景色。 - 通过
windowInsetsPadding
避免内容被系统状态栏/导航栏遮挡。
2. 子组件
- TopBar:顶部导航栏(就是顶端栏)。
- 动态图片显示:
- 用
Image
组件显示图片,并占满父容器的 3.5 份权重(weight(3.5f)
)。
- 用
- HomeActionButtons:封装的动作按钮组,接收
navigateTo
回调以实现导航功能。 - CalendarExample:日历组件(通用组件)。
3. 导航逻辑
- 通过
navigateTo: (String) -> Unit
回调函数,将导航控制权交给父组件(如NavHost
)。 - 点击
HomeActionButtons
中的按钮时,会触发navigateTo
跳转到其他页面。
界面内Box设计
根据我们screen结构我们可以知道,我们有三个部分
│ ├── Image (动态图片)
│ ├── HomeActionButtons (导航按钮)
│ └── CalendarExample (日历通用组件)
动态图片部分
对于动态图片,我们只需要在主目录的drawable里存张好看的图片就行了,我们组byh同学把一整套宝可梦主题的图片存储在该文件夹中了,目前为了简单设计把我们应用的logo放上去了,也就是说目前是静态的。
到时候用Image参数加载路径即可
导航按钮部分
我们需要对于HomeActionButtons.kt
进行实现
根据我们的sealed interface设计的导航
我们可以这么设计这个文件
@Composable
fun HomeActionButtons(
navigateTo: (String) -> Unit,
modifier: Modifier = Modifier
) {
val buttonItems = listOf(
ActionItem(R.drawable.ic_plan, "学习计划", LandingDestination.Main.Plan.route),
ActionItem(R.drawable.ic_search, "搜索", LandingDestination.Main.Search.route),
ActionItem(R.drawable.ic_image_rec, "图片识别翻译", LandingDestination.Main.ImageRec.route),
ActionItem(R.drawable.ic_note, "笔记", LandingDestination.Main.Note.route),
ActionItem(R.drawable.ic_homophony, "谐音", LandingDestination.Main.Homophony.route),
ActionItem(R.drawable.ic_story, "故事", LandingDestination.Main.Story.route),
ActionItem(R.drawable.ic_cartoon, "漫画", LandingDestination.Main.Cartoon.route),
ActionItem(R.drawable.ic_exam, "真题", LandingDestination.Main.Exam.route),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = modifier.fillMaxWidth()
) {
items(items = buttonItems) { item ->
ActionButton(
iconRes = item.iconRes,
label = item.label,
onClick = { navigateTo(item.route) }
)
}
}
}
private data class ActionItem(
val iconRes: Int,
val label: String,
val route: String
)
@Composable
private fun ActionButton(
iconRes: Int,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(80.dp).padding(vertical = 8.dp)
) {
IconButton(
onClick = onClick,
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(iconRes),
contentDescription = label,
tint = Color.Unspecified,
modifier = Modifier.size(45.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground
)
}
}
这段代码实现了一个 水平滚动的动作按钮列表(HomeActionButtons),用于在主页中展示多个功能入口,并通过点击按钮导航到不同的功能页面。
功能概述
-
核心组件:
HomeActionButtons
:主组件,定义了一组可水平滚动的功能按钮。ActionItem
:数据类,封装单个按钮的图标、标签和路由信息。ActionButton
:可复用的按钮组件,显示图标和标签。
-
主要特性:
- 使用
LazyRow
实现水平滚动,避免性能问题(仅渲染可见项)。 - 通过
navigateTo
回调实现页面跳转逻辑(与导航框架解耦)。 - 支持动态配置按钮列表(通过
buttonItems
列表)。
- 使用
设计思路
- 数据层:
ActionItem
private data class ActionItem(
val iconRes: Int, // 图标资源ID(如 R.drawable.ic_plan)
val label: String, // 按钮标签(如 "学习计划")
val route: String // 导航路由(如 LandingDestination.Main.Plan.route)
)
- 将按钮的图标、文本和路由信息封装为数据对象,便于统一管理。
- 按钮组件:
ActionButton
@Composable
private fun ActionButton(
iconRes: Int,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(onClick = onClick) {
Icon(painterResource(iconRes), contentDescription = label)
}
Text(text = label, style = MaterialTheme.typography.labelSmall)
}
}
- 显示一个图标按钮 + 文字标签。
- 点击时触发
onClick
回调(实际调用navigateTo(item.route)
)。 - 遵循 Material Design 规范,使用主题中的颜色和字体样式。
- 列表容器:
HomeActionButtons
@Composable
fun HomeActionButtons(
navigateTo: (String) -> Unit,
modifier: Modifier = Modifier
) {
val buttonItems = listOf(
ActionItem(R.drawable.ic_plan, "学习计划", LandingDestination.Main.Plan.route),
ActionItem(R.drawable.ic_search, "搜索", LandingDestination.Main.Search.route),
// 其他按钮...
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(buttonItems) { item ->
ActionButton(
iconRes = item.iconRes,
label = item.label,
onClick = { navigateTo(item.route) }
)
}
}
}
LazyRow
:高效的水平滚动列表,自动处理内存和性能优化。spacedBy(8.dp)
:按钮间距为 8dp。contentPadding
:左右各留 16dp 内边距,避免内容紧贴屏幕边缘。
items
:动态生成按钮列表,数据来自buttonItems
。
关键设计亮点
-
导航解耦:
- 通过
navigateTo: (String) -> Unit
回调将导航逻辑交给父组件(如NavController
),符合单一职责原则。
- 通过
-
动态配置:
- 修改
buttonItems
列表即可增减按钮,无需改动 UI 逻辑。
- 修改
-
无障碍支持:
Icon
的contentDescription
设置为按钮标签,方便屏幕阅读器识别。
-
样式统一:
- 按钮尺寸、间距、字体样式均通过
Modifier
和MaterialTheme
统一控制。
- 按钮尺寸、间距、字体样式均通过
最终效果
用户会看到一个水平滚动的按钮栏,点击任一按钮将跳转到对应的功能页面(如学习计划、搜索、笔记等)。
组件(日历组件)部分
我们打算设计一个这样的逻辑,这个日历组件可以读取近期学习的数据库,然后,根据某天学习情况,在上面打点,表明当天学习了。
由于数据库还在开发,所以还没有具体的数据连接,所以我们可以初步用数组存好的数据代替一下,等设计好了数据库再连接持久层。
我们可以再ui文件夹里创建一个公共组件文件夹,来存储这个组件
@Composable
fun CalendarWithDots(
yearMonth: YearMonth = YearMonth.now(),
markedDates: Set<LocalDate> = emptySet(),
onDateClick: (LocalDate) -> Unit = {}
) {
val daysInMonth = yearMonth.lengthOfMonth()
val firstDayOfMonth = yearMonth.atDay(1).dayOfWeek
val startOffset = firstDayOfMonth.value % 7 // 0 for Sunday, 1 for Monday, etc.
// Generate all days in month with empty slots for offset
val days = List(42) { index ->
if (index >= startOffset && index < startOffset + daysInMonth) {
yearMonth.atDay(index - startOffset + 1)
} else {
null
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "本月学习情况",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
color = Color(0xFF6200EE), // 直接指定颜色
fontSize = 20.sp, // 字体大小
fontWeight = FontWeight.Bold, // 字体粗细
fontFamily = FontFamily.SansSerif // 字体家族
)
// Month and year header
Text(
text = "${yearMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${yearMonth.year}",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
textAlign = TextAlign.Center
)
// Weekday headers
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
DayOfWeek.values().forEach { dayOfWeek ->
Text(
text = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
// Calendar grid
LazyColumn {
items(days.chunked(7)) { week ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
week.forEach { date ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
if (date != null) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Date number
Text(
text = date.dayOfMonth.toString(),
modifier = Modifier
.clickable { onDateClick(date) }
.padding(8.dp),
textAlign = TextAlign.Center
)
// Green dot if date is marked
if (markedDates.contains(date)) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(Color.Green)
)
}
}
}
}
}
}
}
}
}
}
@Composable
fun CalendarExample() {
val currentMonth = YearMonth.now()
val markedDates = setOf(
currentMonth.atDay(5),
currentMonth.atDay(10),
currentMonth.atDay(15),
currentMonth.atDay(20),
currentMonth.atDay(25)
)
CalendarWithDots(
yearMonth = currentMonth,
markedDates = markedDates,
onDateClick = { date ->
println("Clicked on: $date")
}
)
}
如果你前面的架构能理清楚,这个代码阅读就不是什么问题,这里就不详细讲设计日历的逻辑了,值得注意的一点是:
示例用法 CalendarExample
@Composable
fun CalendarExample() {
val currentMonth = YearMonth.now()
val markedDates = setOf(5, 10, 15, 20, 25).map { currentMonth.atDay(it) }.toSet()
CalendarWithDots(
yearMonth = currentMonth,
markedDates = markedDates,
onDateClick = { date -> println("Clicked on: $date") }
)
}
- 标记日期:示例中标记了当月 5、10、15、20、25 日。
- 点击事件:点击日期时打印日志(实际应用中可能跳转到详情页)。
等到了日后连接了数据库,那就可以,讲数据库数据存储代替这里的setOf里。
经过我们对于以上架构的认证,并且经过长时间的修改封装以上代码,我们初步运行成果
导航bottom行可滑动