VocabVerse背单词应用导航主页开发

主页导航开发

书接上回

我们已经明确了我们的开发组件和目录结构

本文章我们从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 应用中管理不同屏幕之间的切换。以下是它的具体功能:

  1. 导航控制器

    • 使用 rememberNavController() 创建并记住一个导航控制器 (navController),用于管理应用内的导航堆栈和屏幕切换。
  2. 导航图 (NavHost)

    • 通过 NavHost 组件定义应用的导航结构,它是所有可导航目的地的容器。
    • startDestination = "Home" 指定应用启动时显示的初始屏幕。
  3. 定义可组合目的地

    • 使用 composable 函数定义了两个可导航的屏幕:
      • "home" 路由:对应 HomeScreen,并传入 navController 以便从该屏幕导航到其他屏幕。
      • "other" 路由:对应 OtherScreen,同样传入 navController
  4. 功能总结

    • 应用启动时会显示 HomeScreen(通过 "main" 路由)。
    • HomeScreenOtherScreen 中,可以通过 navController 的方法(如 navigate("profile"))切换到另一个屏幕。
    • 支持返回导航(如系统返回按钮或编程方式返回)。

补充说明:

导航逻辑:实际屏幕间的跳转需要在 HomeScreenOtherScreen 中通过按钮等 UI 元素触发,例如调用 navController.navigate("other")

扩展性:可以继续在 NavHost 的 lambda 中添加更多 composable 条目来扩展更多屏幕。

参数传递:如果需要传递参数,可以使用 composablearguments 参数(未在本代码中体现)。

PS:这是一个基础的导航框架,实际开发中可能会结合 ViewModel、路由对象等进一步封装。

那么他们直接怎么互相切换呢,怎么体现导航呢,那么就得借助页面的逻辑设计了(借助于各个组件的viewModel封装)

以下是个例子,具体的实现我们在后面的home设计介绍

// 在可组合函数中
Button(onClick = { navController.navigate("你的导航标签") }) {
    Text("查看详情")
}

路由封装

将路由封装起来是一个很好的实践,尤其是当项目规模增大时,直接使用字符串常量(如 "main")容易导致以下问题:

  1. 难以维护:字符串硬编码容易拼写错误,且修改时需全局搜索。
  2. 缺乏关联信息:路由可能需要附带图标、标签(Label)、参数等额外信息。
  3. 类型不安全:字符串无法在编译时检查有效性。

改进方案:使用 sealed class/interface 封装路由

通过 sealed classsealed interface,可以将路由、图标、标签等统一管理,并提供类型安全的导航。以下是优化后的代码:


  1. 定义路由密封类

将每个路由封装为一个对象,并关联其图标和标签:

// 文件: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
    )

    // 其他路由...
}

  1. 更新导航图

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)
        }
    }
}

  1. 在 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)) }
            )
        }
    }
}

优势总结

  1. 类型安全:所有路由集中管理,避免拼写错误。
  2. 扩展性强:新增路由只需在 Route 密封类中添加一个对象。
  3. 关联信息:图标、标签与路由绑定,避免分散定义。
  4. 代码复用:可在 BottomNavigationDrawer 等组件中复用路由配置。

基于以上架构设计,我们就可以针对我们背单词系统设计很多导航:比如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),用于在主页中展示多个功能入口,并通过点击按钮导航到不同的功能页面。

功能概述

  1. 核心组件

    • HomeActionButtons:主组件,定义了一组可水平滚动的功能按钮。
    • ActionItem:数据类,封装单个按钮的图标、标签和路由信息。
    • ActionButton:可复用的按钮组件,显示图标和标签。
  2. 主要特性

    • 使用 LazyRow 实现水平滚动,避免性能问题(仅渲染可见项)。
    • 通过 navigateTo 回调实现页面跳转逻辑(与导航框架解耦)。
    • 支持动态配置按钮列表(通过 buttonItems 列表)。

设计思路

  1. 数据层:ActionItem
private data class ActionItem(
    val iconRes: Int,    // 图标资源ID(如 R.drawable.ic_plan)
    val label: String,   // 按钮标签(如 "学习计划")
    val route: String    // 导航路由(如 LandingDestination.Main.Plan.route)
)
  • 将按钮的图标、文本和路由信息封装为数据对象,便于统一管理。
  1. 按钮组件: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 规范,使用主题中的颜色和字体样式。
  1. 列表容器: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

关键设计亮点

  1. 导航解耦

    • 通过 navigateTo: (String) -> Unit 回调将导航逻辑交给父组件(如 NavController),符合单一职责原则。
  2. 动态配置

    • 修改 buttonItems 列表即可增减按钮,无需改动 UI 逻辑。
  3. 无障碍支持

    • IconcontentDescription 设置为按钮标签,方便屏幕阅读器识别。
  4. 样式统一

    • 按钮尺寸、间距、字体样式均通过 ModifierMaterialTheme 统一控制。

最终效果

用户会看到一个水平滚动的按钮栏,点击任一按钮将跳转到对应的功能页面(如学习计划、搜索、笔记等)。

组件(日历组件)部分

我们打算设计一个这样的逻辑,这个日历组件可以读取近期学习的数据库,然后,根据某天学习情况,在上面打点,表明当天学习了。

由于数据库还在开发,所以还没有具体的数据连接,所以我们可以初步用数组存好的数据代替一下,等设计好了数据库再连接持久层。

我们可以再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行可滑动

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值