学习Android(五)玩安卓项目实战

简介

在上一章节,我们已经实现了项目架构的搭建,并且将项目需要的东西处理好了,并且实现了登录、注册、首页的实现,这一章我们将继续实现其他页面设计。

这里项目中使用了 [玩Android 开放API-玩Android - wanandroid.com](www.wanandroid.com/index) 提供API,在这里进行声明并感谢大佬

1. TreeFragment 体系界面

fragment_tree.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/swipe_refresh"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".presentation.tree.TreeFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:listitem="@layout/item_tree" />

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

androidx.swiperefreshlayout.widget.SwipeRefreshLayout 使用的时候可能还没有导入依赖,通过 Alt + Enter 提示添加组件依赖即可。

在 Android 中,SwipeRefreshLayout 是一个专门用于为列表或其他可滚动 View 提供"下拉刷新"交互模式的容器组件;它在用户下拉时显示一个 Material 风格的圆形进度指示器,并通过回调通知应用刷新内容。SwipeRefreshLayout 是 AndroidX 库的一部分,支持从 API 14(Android 4.0)及以上版本,并在 Maven Central 上以 androidx.swiperefreshlayout:swiperefreshlayout 的形式发布

TreeContract

kotlin 复制代码
// 定义 Tree 模块的契约接口,包含 Model、View 和 Presenter 三个部分
interface TreeContract : BaseContract {

    // Model 层:负责与数据源交互,获取树形结构数据
    interface Model : BaseContract.IBaseModel {
        /**
         * 异步获取树形结构列表
         * @param listener 回调接口,返回结果为 MutableList<Tree>
         */
        suspend fun getTree(listener: RetrofitResponseListener<MutableList<Tree>>)
    }

    // View 层:负责显示数据或错误信息
    interface View : BaseContract.IBaseView {
        /**
         * 数据请求成功时回调
         * @param treeMutableList 返回的树形结构数据列表
         */
        fun getTreeSuccess(treeMutableList: MutableList<Tree>)

        /**
         * 数据请求失败时回调
         * @param errorMessage 错误信息描述
         */
        fun getTreeError(errorMessage: String)
    }

    // Presenter 层:负责处理 View 请求并协调 Model 获取数据
    interface Presenter : BaseContract.IBasePresenter {
        /**
         * 发起获取树形结构数据的请求
         */
        fun getTree()
    }
}

TreeModel

kotlin 复制代码
// TreeContract.Model 的具体实现类,负责发起获取树形数据的网络请求
class TreeModel : TreeContract.Model {

    /**
     * 从远程接口异步获取树形结构数据
     * @param listener 用于回调请求结果的监听器
     */
    override suspend fun getTree(listener: RetrofitResponseListener<MutableList<Tree>>) {
        // 使用自定义的协程启动方法,封装网络请求与异常处理
        return launchCoroutine({

            // 调用 RetrofitService 获取 API 接口并执行 getTree 请求
            val baseTreeBean = RetrofitService.getApiService().getTree()

            // 根据返回结果的 errorCode 判断请求是否成功
            if (baseTreeBean.errorCode != 0) {
                // 当 errorCode 不为 0 时,调用 onError 回调并传入错误码与错误信息
                listener.onError(baseTreeBean.errorCode, baseTreeBean.errorMsg)
            } else {
                // 当请求成功时,将数据列表通过 onSuccess 回调传递给调用者
                listener.onSuccess(baseTreeBean.data)
            }

            // 协程出错时的统一处理
        }, onError = { e: Throwable ->
            // 打印异常堆栈,便于调试
            e.printStackTrace()
        })
    }
}

TreePresenter

kotlin 复制代码
// TreePresenter:MVP 模式下的 Presenter 实现类
class TreePresenter(view: TreeContract.View) :
// 继承自通用的 BasePresenter,管理 View 和 Model 的生命周期
    BasePresenter<TreeContract.View, TreeContract.Model>(view),
    // 实现 TreeContract 中定义的 Presenter 接口
    TreeContract.Presenter {

    // 创建并返回具体的 Model 实例,用于执行数据请求
    override fun createModel(): TreeContract.Model = TreeModel()

    // 发起获取树形数据的请求
    override fun getTree() {
        // 在协程作用域内启动新的协程,不阻塞当前线程
        coroutineScope.launch {
            // 调用 Model 层的 getTree 方法,并传入回调监听器
            mModel?.getTree(object : RetrofitResponseListener<MutableList<Tree>> {
                // 当数据请求成功时,通知 View 层展示数据
                override fun onSuccess(response: MutableList<Tree>) {
                    mView?.get()?.getTreeSuccess(response)
                }

                // 当数据请求失败时,通知 View 层展示错误信息
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getTreeError(errorMessage)
                }
            })
        }
    }
}

布局中使用了 RecyclerView 这里提供布局

item_tree.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/white"
    android:foreground="?android:attr/selectableItemBackground"
    android:orientation="vertical"
    android:padding="@dimen/dp_20">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv_tree_title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="标题"
            android:textColor="@color/black"
            android:textSize="@dimen/sp_16"
            android:textStyle="bold" />

        <ImageView
            android:id="@+id/iv_toggle"
            android:layout_width="@dimen/dp_20"
            android:layout_height="@dimen/dp_20"
            android:contentDescription="@null"
            android:src="@drawable/ic_down"
            android:textColor="@color/black"
            android:textSize="@dimen/sp_16" />

    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_item_tree"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:listitem="@layout/item_tree_item" />

</LinearLayout>

TreeAdapter

kotlin 复制代码
// TreeAdapter:基于 BaseQuickAdapter 显示 Tree 列表并支持子项点击回调
class TreeAdapter : BaseQuickAdapter<Tree, BaseViewHolder>(R.layout.item_tree) {
    /**
     * 数据与视图绑定方法,每个列表项会调用一次
     * @param holder 当前项的 ViewHolder
     * @param item 当前项的 Tree 数据对象
     */
    override fun convert(
        holder: BaseViewHolder,
        item: Tree
    ) {
        // 设置父节点标题文本
        holder.setText(R.id.tv_tree_title, item.name)
        // 获取父项布局中的内层 RecyclerView,用于显示子列表
        val rvItemTree = holder.getView<RecyclerView>(R.id.rv_item_tree)

        // 根据 item.isShow 控制子列表的可见性和指示图标方向
        if (item.isShow) {
            rvItemTree.visibility = View.VISIBLE
            holder.setImageResource(R.id.iv_toggle, R.drawable.ic_up)
        } else {
            rvItemTree.visibility = View.GONE
            holder.setImageResource(R.id.iv_toggle, R.drawable.ic_down)
        }

        // 创建子列表的适配器,传入当前父项的位置和点击回调
        val treeChildAdapter = TreeChildAdapter(holder.adapterPosition, childClickListener)

        // 使用瀑布流布局显示子项,这里示例纵向 8 列排列
        rvItemTree.layoutManager = StaggeredGridLayoutManager(
            8,
            StaggeredGridLayoutManager.VERTICAL
        )
        // 绑定子适配器
        rvItemTree.adapter = treeChildAdapter

        // 将当前父节点的 children 数据设置到子适配器
        treeChildAdapter.setList(item.children)
    }

    // 定义子项点击回调接口,供外部注册以接收事件
    interface OnChildClickListener {
        /**
         * @param parentPos 父项在外层列表中的位置
         * @param childPos 子项在当前子列表中的位置
         * @param child 对应的 Children 数据对象
         */
        fun onChildClick(parentPos: Int, childPos: Int, child: Children)
    }

    // 存储外部设置的回调实现
    private var childClickListener: OnChildClickListener? = null
    /**
     * 注册子项点击事件监听器
     * @param listener OnChildClickListener 实现,用于回调子项点击
     */
    fun setOnChildClickListener(listener: OnChildClickListener) {
        childClickListener = listener
    }

}

StaggeredGridLayoutManager 是 Android 中 RecyclerView 的一种布局管理器,用于实现瀑布流(交错网格)布局。与传统的 GridLayoutManager 不同,它允许每个子项具有不同的高度或宽度,从而创建出错落有致的视觉效果,常用于展示图片、商品等内容

我们可以发现,item 布局中还有一个 RecyclerView

item_tree_item.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tv_tag"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="@dimen/dp_8"
    android:layout_marginRight="@dimen/dp_8"
    android:background="@drawable/shape_eaeaea_oval"
    android:foreground="?android:attr/selectableItemBackground"
    android:paddingLeft="@dimen/dp_8"
    android:paddingTop="@dimen/dp_4"
    android:paddingRight="@dimen/dp_8"
    android:paddingBottom="@dimen/dp_4"
    android:text="@string/app_name"
    android:textAppearance="?android:textAppearanceSmall"
    android:textColor="@color/color_757575" />

TreeChildAdapter

kotlin 复制代码
// TreeChildAdapter:用于展示子标签并处理点击事件的适配器,继承自 BaseQuickAdapter
class TreeChildAdapter(
    // 父项在外层适配器中的位置,用于回调时区分父节点
    private val parentPos: Int,
    // 接收外层注册的子项点击回调接口
    private val listener: TreeAdapter.OnChildClickListener?
) : BaseQuickAdapter<Children, BaseViewHolder>(R.layout.item_tree_item) {

    /**
     * 将 Children 数据绑定到视图,每个子项调用一次
     * @param holder 当前项的 ViewHolder
     * @param item 当前 Children 数据对象
     */
    override fun convert(
        holder: BaseViewHolder,
        item: Children
    ) {
        // 设置标签文本
        holder.setText(R.id.tv_tag, item.name)
        // 设置随机文字颜色,保持与父适配器中同样的视觉效果
        holder.setTextColor(R.id.tv_tag, randomColor())
        // 获取标签 TextView 并设置点击事件
        holder.getView<TextView>(R.id.tv_tag).setOnClickListener {
            // 当子项被点击时,通过 listener 回调,并传递父位置、子位置和数据对象
            listener?.onChildClick(parentPos, holder.adapterPosition, item)
        }
    }
}

TreeFragment

kotlin 复制代码
// TreeFragment:MVP 模式下用于展示 Tree 列表的 Fragment,
// 继承自 BaseMVPFragment 并实现了 TreeContract.View 接口
class TreeFragment :
    BaseMVPFragment<FragmentTreeBinding, TreeContract.Presenter>({ FragmentTreeBinding.inflate(it) }),
    TreeContract.View {   // 实现 View 层回调接口

    // 当前选中的父项位置,用于在子标签点击时获取对应的父节点数据
    private var mPosition: Int = 0

    // 存储从 Presenter 获取到的 Tree 数据列表
    private lateinit var mTreeList: MutableList<Tree>

    // 初始化视图:在 Fragment 创建后调用,用于配置 UI 组件
    override fun initView() {
        initSwipeRefreshLayout()  // 初始化下拉刷新组件
    }

    // 初始化或恢复数据:在视图初始化后调用,用于发起数据请求
    override fun initData() {
        mPresenter.getTree()  // 调用 Presenter 执行网络请求获取 Tree 数据
    }

    // 统一点击事件入口(此处留空,如需可复用)
    override fun allClick() {
    }

    // 创建 Presenter 实例的方法,实现契约中指定的工厂方法
    override fun createPresenter(): TreeContract.Presenter {
        return TreePresenter(this)  // 将当前 Fragment 作为 View 传入 Presenter
    }

    // 配置下拉刷新控件的样式和刷新回调
    private fun initSwipeRefreshLayout() {
        binding.swipeRefresh.setColorSchemeResources(
            android.R.color.holo_blue_bright,   // 刷新进度条颜色
            android.R.color.holo_green_light,
            android.R.color.holo_orange_light
        )
        // 下拉刷新触发时回调
        binding.swipeRefresh.setOnRefreshListener {
            // 延迟用于模拟网络请求时长
            binding.swipeRefresh.postDelayed({
                mPresenter.getTree()                   // 重新请求数据
                binding.swipeRefresh.isRefreshing = false // 关闭刷新动画
            }, 1500)
        }
    }

    // Presenter 获取 Tree 数据成功后的回调
    override fun getTreeSuccess(treeMutableList: MutableList<Tree>) {
        mTreeList = treeMutableList  // 保存数据到成员变量

        // 创建并配置 TreeAdapter
        val treeAdapter = TreeAdapter().apply {
            // 父项点击事件:控制展开/收起逻辑
            setOnItemClickListener { _, _, position ->
                mPosition = position  // 记录当前点击的父项位置

                // 如果当前项已展开,则全部折叠;否则先折叠所有,再展开当前项
                if (treeMutableList[position].isShow) {
                    treeMutableList.forEach { it.isShow = false }
                } else {
                    treeMutableList.forEach { it.isShow = false }
                    treeMutableList[position].isShow = true
                }
                notifyDataSetChanged()  // 刷新列表,应用展开/折叠状态
            }

            // 子标签点击事件:跳转到 TreeChildActivity 并传递对应数据
            setOnChildClickListener(object : TreeAdapter.OnChildClickListener {
                override fun onChildClick(
                    parentPos: Int,
                    childPos: Int,
                    child: Children
                ) {
                    // 构建跳转 Intent,传入标题(父节点 name)、子列表及子项位置
                    val intent = Intent(binding.root.context, TreeChildActivity::class.java).apply {
                        putExtra(TreeChildActivity.TITLE, mTreeList[parentPos].name)
                        putExtra(TreeChildActivity.CID, mTreeList[parentPos].children)
                        putExtra(TreeChildActivity.POSITION, childPos)
                    }
                    startActivity(intent)  // 启动子页面
                }
            })
        }

        // 为 RecyclerView 添加垂直分割线
        binding.recyclerView.addItemDecoration(
            DividerItemDecoration(
                binding.root.context,
                LinearLayoutManager.VERTICAL
            )
        )

        // 绑定适配器并提交数据列表
        binding.recyclerView.adapter = treeAdapter
        treeAdapter.setList(treeMutableList)
    }

    // Presenter 获取数据失败时的回调,展示错误信息
    override fun getTreeError(errorMessage: String) {
        show(errorMessage) // 调用基类方法(如 Toast)提示用户
    }
}

至此,我们界面通过 RecyclerView 垂直展示体系列表,通过点击列表展开对应体系下的二级列表,通过点击二级列表的 item 我们可以查看对应体系详情

activity_tree_child.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".presentation.tree.child.TreeChildActivity">

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        app:tabIndicatorColor="@color/colorPrimary"
        app:tabIndicatorFullWidth="false"
        app:tabIndicatorHeight="3dp"
        app:tabMode="scrollable"
        app:tabSelectedTextColor="@color/colorPrimary"
        app:tabTextAppearance="@style/TabLayoutTextStyle"
        app:tabTextColor="@color/gray" />

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />


</LinearLayout>

通过布局文件我们可以知道体系详情界面是导航+分页的效果,分页我们一般加载 Fragment

fragment_tree_child.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".presentation.tree.child.TreeChildFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:listitem="@layout/item_article" />

</FrameLayout>

TreeChildContract

kotlin 复制代码
// TreeChildContract:树形列表子页面的 MVP 契约,定义了 Model、View 和 Presenter 层接口
interface TreeChildContract : BaseContract {

    // Model 层:负责数据获取与业务请求,继承自 IBaseModel
    interface Model : BaseContract.IBaseModel {
        /**
         * 获取指定分类的文章列表
         * @param page 页码,从 0 或 1 开始
         * @param cid 分类 ID
         * @param listener 网络请求回调,返回 Article 对象
         */
        suspend fun getTreeChild(page: Int, cid: Int, listener: RetrofitResponseListener<Article>)

        /**
         * 获取更多文章列表(分页加载)
         * @param page 下一页页码
         * @param cid 分类 ID
         * @param listener 网络请求回调,返回 Article 对象
         */
        suspend fun getTreeMoreChild(page: Int, cid: Int, listener: RetrofitResponseListener<Article>)

        /**
         * 收藏指定文章
         * @param id 文章 ID
         * @param listener 收藏结果回调,返回成功消息
         */
        suspend fun collect(id: Int, listener: RetrofitResponseListener<String>)

        /**
         * 取消收藏指定文章
         * @param id 文章 ID
         * @param listener 取消收藏结果回调,返回成功消息
         */
        suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>)
    }

    // View 层:定义 UI 更新方法,继承自 IBaseView
    interface View : BaseContract.IBaseView {
        /**
         * 获取文章列表成功回调
         * @param article 返回的 Article 数据
         */
        fun getTreeChildSuccess(article: Article)
        /**
         * 获取文章列表失败回调
         * @param errorMessage 错误描述
         */
        fun getTreeChildError(errorMessage: String)

        /**
         * 分页加载更多文章成功回调
         * @param article 返回的 Article 数据
         */
        fun getTreeMoreChildSuccess(article: Article)
        /**
         * 分页加载更多文章失败回调
         * @param errorMessage 错误描述
         */
        fun getTreeMoreChildError(errorMessage: String)

        /**
         * 收藏成功回调
         * @param successMessage 成功消息文本
         */
        fun collectSuccess(successMessage: String)
        /**
         * 收藏失败回调
         * @param errorMessage 错误描述
         */
        fun collectError(errorMessage: String)

        /**
         * 取消收藏成功回调
         * @param successMessage 成功消息文本
         */
        fun unCollectSuccess(successMessage: String)
        /**
         * 取消收藏失败回调
         * @param errorMessage 错误描述
         */
        fun unCollectError(errorMessage: String)

        /**
         * 未登录或需要登录时的提示回调
         * @param msg 登录提示消息
         */
        fun login(msg: String)
    }

    // Presenter 层:定义与 View 交互的方法,继承自 IBasePresenter
    interface Presenter : BaseContract.IBasePresenter {
        /**
         * 请求获取文章列表
         * @param page 页码
         * @param cid 分类 ID
         */
        fun getTreeChild(page: Int, cid: Int)

        /**
         * 请求加载更多文章(分页)
         * @param page 下一页页码
         * @param cid 分类 ID
         */
        fun getTreeMoreChild(page: Int, cid: Int)

        /**
         * 请求收藏文章
         * @param id 文章 ID
         */
        fun collect(id: Int)

        /**
         * 请求取消收藏文章
         * @param id 文章 ID
         */
        fun unCollect(id: Int)
    }
}

TreeChildModel

kotlin 复制代码
// TreeChildModel:实现 TreeChildContract.Model,负责处理子列表数据及收藏/取消收藏网络请求
class TreeChildModel : TreeChildContract.Model {
    /**
     * 获取分类文章列表
     * @param page 页码,用于分页请求
     * @param cid 分类 ID
     * @param listener 请求回调,返回 Article 对象
     */
    override suspend fun getTreeChild(
        page: Int,
        cid: Int,
        listener: RetrofitResponseListener<Article>
    ) {
        // 使用自定义协程封装,自动处理异常和线程切换
        return launchCoroutine({
            // 发起网络请求,获取当前页的子分类文章数据
            val baseTreeChildBean =
                RetrofitService.getApiService().getTreeChild(page, cid)
            // 根据返回的 errorCode 判定请求是否成功
            if (baseTreeChildBean.errorCode != 0) {
                // 请求失败时调用 onError,传递错误码和消息
                listener.onError(baseTreeChildBean.errorCode, baseTreeChildBean.errorMsg)
            } else {
                // 请求成功时调用 onSuccess,将数据传递给 View 层
                listener.onSuccess(baseTreeChildBean.data)
            }
        }, onError = { e: Throwable ->
            // 协程内抛出异常时打印堆栈,便于调试
            e.printStackTrace()
        })
    }

    /**
     * 分页加载更多分类文章
     * @param page 下一页页码
     * @param cid 分类 ID
     * @param listener 请求回调,返回 Article 对象
     */
    override suspend fun getTreeMoreChild(
        page: Int,
        cid: Int,
        listener: RetrofitResponseListener<Article>
    ) {
        // 与 getTreeChild 逻辑相同,调用相同接口进行分页请求
        return launchCoroutine({
            val baseTreeChildBean =
                RetrofitService.getApiService().getTreeChild(page, cid)
            if (baseTreeChildBean.errorCode != 0) {
                listener.onError(baseTreeChildBean.errorCode, baseTreeChildBean.errorMsg)
            } else {
                listener.onSuccess(baseTreeChildBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 收藏文章
     * @param id 文章 ID
     * @param listener 请求回调,返回操作结果提示
     */
    override suspend fun collect(id: Int, listener: RetrofitResponseListener<String>) {
        // 封装协程调用,发起收藏请求
        return launchCoroutine({
            val baseCollectBean = RetrofitService.getApiService().collect(id)
            // 根据返回码调用不同回调
            if (baseCollectBean.errorCode != 0) {
                listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
            } else {
                listener.onSuccess("收藏成功")  // 硬编码成功提示
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 取消收藏文章
     * @param id 文章 ID
     * @param listener 请求回调,返回操作结果提示
     */
    override suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>) {
        // 封装协程调用,发起取消收藏请求
        return launchCoroutine({
            val baseCollectBean = RetrofitService.getApiService().unCollect(id)
            if (baseCollectBean.errorCode != 0) {
                listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
            } else {
                listener.onSuccess("取消收藏成功")  // 硬编码取消收藏提示
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }
}

TreeChildPresenter

kotlin 复制代码
// TreeChildPresenter:处理子树页面的 Presenter,实现 TreeChildContract.Presenter 接口
class TreeChildPresenter(view: TreeChildContract.View) :
// 继承自通用 BasePresenter,关联 View 和 Model
    BasePresenter<TreeChildContract.View, TreeChildContract.Model>(view),
    TreeChildContract.Presenter {  // 实现契约中的 Presenter 方法

    /**
     * 创建并返回 Model 层实例,用于发起网络请求
     */
    override fun createModel(): TreeChildContract.Model = TreeChildModel()

    /**
     * 请求获取当前分类的文章列表
     * @param page 页码,用于分页加载
     * @param cid 分类 ID
     */
    override fun getTreeChild(page: Int, cid: Int) {
        // 在 CoroutineScope 中启动协程,防止阻塞主线程
        coroutineScope.launch {
            mModel?.getTreeChild(page, cid, object : RetrofitResponseListener<Article> {
                /**
                 * 网络请求成功时回调,将结果传递给 View 层
                 */
                override fun onSuccess(response: Article) {
                    mView?.get()?.getTreeChildSuccess(response)
                }

                /**
                 * 网络请求失败时回调,将错误信息传递给 View 层
                 */
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getTreeChildError(errorMessage)
                }

            })
        }
    }

    /**
     * 请求加载更多文章(分页)
     * @param page 下一页页码
     * @param cid 分类 ID
     */
    override fun getTreeMoreChild(page: Int, cid: Int) {
        coroutineScope.launch {
            mModel?.getTreeMoreChild(page, cid, object : RetrofitResponseListener<Article> {
                override fun onSuccess(response: Article) {
                    mView?.get()?.getTreeMoreChildSuccess(response)
                }

                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getTreeMoreChildError(errorMessage)
                }

            })
        }
    }

    /**
     * 请求收藏文章
     * @param id 文章 ID
     */
    override fun collect(id: Int) {
        coroutineScope.launch {
            mModel?.collect(id, object : RetrofitResponseListener<String> {
                /**
                 * 收藏成功时回调,通知 View 更新收藏状态
                 */
                override fun onSuccess(response: String) {
                    mView?.get()?.collectSuccess("收藏成功")
                }

                /**
                 * 收藏失败时回调,特殊处理登出状态
                 * @param errorCode 错误码,-1001 通常表示未登录或登录过期
                 */
                override fun onError(errorCode: Int, errorMessage: String) {
                    if (errorCode == -1001) {
                        // 未登录或登录失效,提示 View 跳转登录
                        mView?.get()?.login(errorMessage)
                    } else {
                        // 其他错误直接通知收藏失败
                        mView?.get()?.collectError("收藏失败")
                    }
                }

            })
        }
    }

    /**
     * 请求取消收藏文章
     * @param id 文章 ID
     */
    override fun unCollect(id: Int) {
        coroutineScope.launch {
            mModel?.collect(id, object : RetrofitResponseListener<String> {
                /**
                 * 取消收藏成功时回调,通知 View 更新状态
                 */
                override fun onSuccess(response: String) {
                    mView?.get()?.unCollectSuccess("取消收藏成功")
                }

                /**
                 * 取消收藏失败时回调,通知 View 展示失败提示
                 */
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.unCollectError("取消收藏失败")
                }

            })
        }
    }
}

TreeChildFragment

kotlin 复制代码
// TreeChildFragment:展示子章节文章列表的 Fragment,支持下拉加载、分页、文章点击与收藏操作
class TreeChildFragment : BaseMVPFragment<FragmentTreeChildBinding, TreeChildContract.Presenter>({
    FragmentTreeChildBinding.inflate(it)
}), TreeChildContract.View, OnLoadMoreListener, OnItemClickListener,
    OnItemChildClickListener {

    companion object {
        // 每次请求的文章数量常量
        private const val TOTAL_COUNTER = 20
        // 已加载文章数量
        private var CURRENT_SIZE = 0
        // 当前请求页码
        private var CURRENT_PAGE = 0

        // Intent 传递的分类 ID Key
        const val CID: String = "cid"

        /**
         * 静态工厂方法,根据分类 ID 创建新的 Fragment 实例
         */
        fun newInstance(cid: Int): TreeChildFragment {
            val fragment = TreeChildFragment()
            val bundle = Bundle()
            bundle.putInt(CID, cid)
            fragment.arguments = bundle
            return fragment
        }
    }

    // 当前分类 ID,从 arguments 中获取
    private var mCid: Int = 0
    // 存储已加载的文章列表
    private lateinit var mDataList: MutableList<ArticleDetail>
    // BRVAH 文章列表适配器
    private lateinit var mArticleAdapter: ArticleAdapter
    // 当前点击或收藏操作的文章位置
    private var mPosition: Int = 0

    /**
     * 初始化视图,可在此设置 RecyclerView、刷新控件等(此处留空)
     */
    override fun initView() {
    }

    /**
     * 初始化数据,获取传递的分类 ID 并发起首屏数据请求
     */
    override fun initData() {
        // 从 arguments 中取出分类 ID
        mCid = arguments?.getInt(CID)!!
        // 请求第一页文章列表
        mPresenter.getTreeChild(CURRENT_PAGE, mCid)
    }

    // 统一点击事件入口(若有其他全局点击,可在此处理)
    override fun allClick() {
    }

    /**
     * 创建 Presenter 实例,用于后续业务调用
     */
    override fun createPresenter(): TreeChildContract.Presenter {
        return TreeChildPresenter(this)
    }

    /**
     * 分页加载更多回调,当列表滑动到底部时触发
     */
    override fun onLoadMore() {
        if (CURRENT_SIZE < TOTAL_COUNTER) {
            // 如果当前数据不足一页,直接结束加载并显示尾部
            mArticleAdapter.loadMoreModule.loadMoreEnd(true)
        } else {
            // 否则增加页码并请求下一页数据
            CURRENT_PAGE++
            mPresenter.getTreeMoreChild(CURRENT_PAGE, mCid)
        }
    }

    /**
     * 列表项点击回调,跳转到文章详情页面
     */
    override fun onItemClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
        val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
            putExtra(DetailActivity.WEB_URL, mDataList[position].link)
            putExtra(DetailActivity.WEB_TITLE, mDataList[position].title)
        }
        startActivity(intent)
    }

    /**
     * 子控件(收藏按钮)点击回调,执行收藏或取消收藏操作
     */
    override fun onItemChildClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
        mPosition = position
        // 根据当前收藏状态调用不同的 Presenter 方法
        if (mDataList[position].collect) {
            mPresenter.unCollect(mDataList[position].id)
        } else {
            mPresenter.collect(mDataList[position].id)
        }
    }

    /**
     * 首次请求或刷新文章列表成功回调
     */
    override fun getTreeChildSuccess(article: Article) {
        // 更新已加载数量
        CURRENT_SIZE = article.datas.size
        // 保存数据源
        mDataList = article.datas
        // 初始化并配置 Adapter
        mArticleAdapter = ArticleAdapter().apply {
            setOnItemClickListener(this@TreeChildFragment)       // 注册列表项点击
            setOnItemChildClickListener(this@TreeChildFragment)  // 注册子控件点击
            loadMoreModule.setOnLoadMoreListener(this@TreeChildFragment) // 注册加载更多
        }
        // 绑定 Adapter 到 RecyclerView
        binding.recyclerView.adapter = mArticleAdapter
        // 提交数据列表
        mArticleAdapter.setList(article.datas)
    }

    /**
     * 首次请求或刷新文章列表失败回调,展示错误信息
     */
    override fun getTreeChildError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 加载更多数据成功回调
     */
    override fun getTreeMoreChildSuccess(article: Article) {
        // 更新当前页数据量
        CURRENT_SIZE = article.datas.size
        // 将新数据追加到列表中
        mDataList.addAll(article.datas)
        mArticleAdapter.addData(article.datas)
        // 通知加载更多模块加载完成
        mArticleAdapter.loadMoreModule.loadMoreComplete()
    }

    /**
     * 加载更多数据失败回调,展示错误信息
     */
    override fun getTreeMoreChildError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 收藏成功回调,更新数据状态并刷新列表
     */
    override fun collectSuccess(successMessage: String) {
        ToastUtil.showCenter(successMessage)
        mDataList[mPosition].collect = true
        mArticleAdapter.notifyDataSetChanged()
    }

    /**
     * 收藏失败回调,展示错误信息
     */
    override fun collectError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 取消收藏成功回调,更新数据状态并刷新列表
     */
    override fun unCollectSuccess(successMessage: String) {
        ToastUtil.showCenter(successMessage)
        mDataList[mPosition].collect = false
        mArticleAdapter.notifyDataSetChanged()
    }

    /**
     * 取消收藏失败回调,展示错误信息
     */
    override fun unCollectError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 未登录或登录失效时回调,弹出登录对话框引导用户登录
     */
    override fun login(msg: String) {
        val builder = AlertDialog.Builder(binding.root.context).apply {
            setTitle("提示")
            setMessage(msg)
            setPositiveButton("确定") { _, _ ->
                startActivity(Intent(binding.root.context, LoginActivity::class.java))
            }
            setNegativeButton("取消", null)
        }
        builder.create().show()
    }
}

TreeChildActivity

kotlin 复制代码
// TreeChildActivity:用于展示子分类详情的 Activity,继承自支持 ViewBinding 的 BaseActivity
class TreeChildActivity :
    BaseActivity<ActivityTreeChildBinding>({ ActivityTreeChildBinding.inflate(it) }) {

    companion object {
        // Intent 传递的标题 Key
        var TITLE: String = "title"
        // Intent 传递的子分类列表 Key
        var CID: String = "cid"
        // Intent 传递的初始选中位置 Key
        var POSITION: String = "position"
    }

    // 初始化视图组件,设置 Toolbar 标题与返回按钮
    override fun initView() {
        // 从 Intent 中获取标题并设置到 Toolbar
        setBarTitle(intent.getStringExtra(TITLE)!!)
        // 启用左上角返回按钮
        setBackEnabled()
    }

    // 初始化数据和子页面布局,动态创建 Fragment 并绑定到 ViewPager
    override fun initData() {
        // 从 Intent 中获取传递的子分类列表,并转换为 ArrayList<Children>
        val childList: ArrayList<Children> = intent.getSerializableExtra(CID) as ArrayList<Children>
        // 提取各子分类的名称,用于 Tab 标题
        val titles = java.util.ArrayList<String>()
        for (i in childList.indices) {
            titles.add(childList[i].name)
        }

        // 创建通用的 ViewPager 适配器,传入 FragmentManager 与标题列表
        val commonViewPagerAdapter = CommonViewPageAdapter(supportFragmentManager, titles)
        // 根据每个子分类的 ID 动态添加对应的 Fragment
        for (index in titles.indices) {
            commonViewPagerAdapter.addFragment(TreeChildFragment.newInstance(childList[index].id))
        }
        // 为 ViewPager 设置适配器,完成 Fragment 与标题的绑定
        binding.viewPager.adapter = commonViewPagerAdapter

        // 从 Intent 获取初始显示的位置,并设置到 ViewPager
        val index = intent.getIntExtra(POSITION, 0)
        binding.viewPager.currentItem = index

        // 将 TabLayout 与 ViewPager 联动,自动显示各页签标题并支持点击切换
        binding.tabLayout.setupWithViewPager(binding.viewPager)
    }

    // 统一点击事件处理(此 Activity 中暂无额外点击事件)
    override fun allClick() {
    }
}

至此,我们体系界面就完成了,因为还未做退出操作,建议卸载APK重装,重走流程,防止有bug,通过编写上述代码,我们可以发现,编写界面实现效果,真的不难,尤其是有规范之后,在有明确的UI布局设计的时候,就是工厂流程的方式去实现,这就是管理项目所带来便捷,以此类推我们的项目界面、导航界面、其实都一样如此简单

当我们运行之后发现效果如下

由于我们设置了 AndroidMainfest.xml 中主题为

xml 复制代码
<style name="AppTheme.NoActionBar">
    <item name="windowActionBar">false</item>
    <item name="windowNoTitle">true</item>
</style>

彻底移除系统默认的 ActionBar 和窗口标题,这样导致界面并不是很好看,那么我们该怎么办呢?其实很简单,没有我们就去手动创建,修改 activity_main.xml 布局

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.main.MainActivity">

    <!-- 头部布局,wrap_content 或 0dp+约束即可自适应高度 -->
    <include
        android:id="@+id/include_main"
        layout="@layout/app_bar_main"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- 主内容区:viewPager + BottomNavigationView -->
    <LinearLayout
        android:id="@+id/linear_content"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:orientation="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/include_main">

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/view_pager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottom_navigation"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="?android:attr/windowBackground"
            app:menu="@menu/bottom_navigation" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

app_bar_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>


</androidx.coordinatorlayout.widget.CoordinatorLayout>

MainActivity 中设置

kotlin 复制代码
class MainActivity : BaseActivity<ActivityMainBinding>({ ActivityMainBinding.inflate(it) }) {

    override fun initView() {
        // 设置 Toolbar 标题为 App 名称
        binding.includeMain.toolbar.title = getString(R.string.app_name)
        // 将布局文件中的 Toolbar 作为 Activity 的 ActionBar 使用,
        // 这样就可以调用 getSupportActionBar() 等方法来控制 App Bar 行为 
        setSupportActionBar(binding.includeMain.toolbar)
        .
        .
        .
    }
    .
    .
    .

    /**
     * 注册所有点击和滑动事件监听
     */
    override fun allClick() {
        /**
         * ViewPager 滑动监听:
         * 当页面滑动时,根据当前位置更新 BottomNavigationView 的选中状态
         */
        binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
            // 页面正在滚动时回调 :contentReference[oaicite:1]{index=1}
            override fun onPageScrolled(
                position: Int,
                positionOffset: Float,
                positionOffsetPixels: Int
            ) {
                .
                .
                .
                // 滑动时更新 Toolbar 标题,因为 setChecked 不会触发选中事件
                when (position) {
                    0 -> binding.includeMain.toolbar.title = resources.getString(R.string.app_name)
                    1 -> binding.includeMain.toolbar.title = resources.getString(R.string.title_tree)
                    2 -> binding.includeMain.toolbar.title = resources.getString(R.string.title_navi)
                    else -> binding.includeMain.toolbar.title = resources.getString(R.string.title_project)
                }
            }

        })
        .
        .
        .
}

完成上述,卸载后重新运行一下,发现流程没有任何问题,并且自定义导航栏添加成功,接下来我们来实现导航界面

NaviContract

kotlin 复制代码
interface NaviContract : BaseContract {
    // 定义导航功能的契约接口,继承自 BaseContract,保证所有模块的契约风格一致。
    interface Model : BaseContract.IBaseModel {

        // Model 负责数据层操作,继承通用的 IBaseModel 接口以获取基础功能。
        suspend fun getNavi(listener: RetrofitResponseListener<MutableList<Navi>>)

        // 使用 Retrofit 在协程中发起异步网络请求获取导航数据,suspend 关键字表示该函数可挂起,不会阻塞线程。
    }

    interface View : BaseContract.IBaseView {

        // View 接口定义了界面层的回调方法,继承自 IBaseView 以复用通用 UI 行为。
        fun getNaviSuccess(naviList: MutableList<Navi>)

        // 当数据请求成功时调用,传入导航列表用于更新界面。
        fun getNaviError(errorMessage: String)
        // 当数据请求失败时调用,传入错误信息以便在界面上提示用户。
    }

    interface Presenter : BaseContract.IBasePresenter {
        // Presenter 充当中间人,协调 Model 与 View 的交互,符合 MVP 架构模式。
        fun getNavi()
        // 启动导航数据获取流程,调用 Model#getNavi 并将结果分发给 View。
    }
}

NaviModel

kotlin 复制代码
class NaviModel : NaviContract.Model {
    // 实现 NaviContract.Model 接口,承接导航数据的获取逻辑,不改动原有接口定义。
    override suspend fun getNavi(listener: RetrofitResponseListener<MutableList<Navi>>) {
        // 使用自定义的 launchCoroutine 扩展函数在协程中执行异步任务,避免阻塞主线程。
        return launchCoroutine({
            // 调用 RetrofitService 获取 ApiService 实例,并挂起函数 getNavi 发起网络请求。
            val baseNaviBean = RetrofitService.getApiService().getNavi()
            // 根据返回的 errorCode 判断请求是否成功,非 0 表示失败。
            if (baseNaviBean.errorCode != 0) {
                // 失败时通过 listener 回调 onError,传递错误码和错误信息给上层处理。
                listener.onError(baseNaviBean.errorCode, baseNaviBean.errorMsg)
            } else {
                // 成功时通过 listener 回调 onSuccess,将获取到的数据列表传递给 View 显示。
                listener.onSuccess(baseNaviBean.data)
            }
        }, onError = { e: Throwable ->
            // 对网络或其他异常进行统一捕获,并打印堆栈信息,方便调试和日志记录。
            e.printStackTrace()
        })
    }
}

NaviPresenter

kotlin 复制代码
class NaviPresenter(view: NaviContract.View) :
    BasePresenter<NaviContract.View, NaviContract.Model>(view), NaviContract.Presenter {
    // 将 View 注入到 BasePresenter 中,确保 Presenter 拥有对 View 的引用和生命周期控制。
    override fun createModel(): NaviContract.Model = NaviModel()
    // 创建并返回具体的 Model 实例,用于执行数据请求逻辑。

    override fun getNavi() {
        // 在 Presenter 的 CoroutineScope 中启动协程,保障结构化并发,避免内存泄漏。
        coroutineScope.launch {
            // 调用 Model 的 getNavi 方法发起异步网络请求,并通过回调接口处理结果。
            mModel?.getNavi(object : RetrofitResponseListener<MutableList<Navi>> {
                override fun onSuccess(response: MutableList<Navi>) {
                    // 请求成功时,通过 View 接口回调并传递数据,更新界面展示。
                    mView?.get()?.getNaviSuccess(response)
                }

                override fun onError(errorCode: Int, errorMessage: String) {
                    // 请求失败时,通过 View 接口回调并传递错误信息,用于界面提示或错误处理。
                    mView?.get()?.getNaviError(errorMessage)
                }

            })
        }
    }

}

fragment_navi.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.navi.NaviFragment">


    <!-- 左侧导航 -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/left_navigation"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        tools:listitem="@layout/item_navi_left"
        android:background="#6B6B6B"/>

    <!-- 左侧导航 -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/right_navi"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="7"
        tools:listitem="@layout/item_navi_left"/>

</LinearLayout>

item_navi_left.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:layout_gravity="center"
    android:layout_margin="@dimen/dp_10"
    android:gravity="center"
    android:background="@drawable/bg_left_item_selector">

    <TextView
        android:id="@+id/text_category"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textColor="@drawable/left_text_selector"
        android:layout_gravity="center"
        android:text="开放平台"
        android:textSize="16sp" />

</LinearLayout>

bg_left_item_selector.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <solid android:color="#FFFFFF" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@android:color/transparent" />
        </shape>
    </item>
</selector>

left_text_selector.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true" android:color="@color/black" />
    <item android:color="@color/color_eaeaea" />
</selector>

item_navi_right.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tv_tag"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginLeft="@dimen/dp_15"
    android:layout_marginTop="@dimen/dp_15"
    android:background="@drawable/shape_eaeaea_oval"
    android:foreground="?android:attr/selectableItemBackground"
    android:paddingLeft="@dimen/dp_15"
    android:paddingTop="@dimen/dp_8"
    android:paddingRight="@dimen/dp_15"
    android:paddingBottom="@dimen/dp_8"
    android:text="@string/app_name"
    android:textAppearance="?android:textAppearanceSmall"
    android:textColor="@color/color_757575" />

NaviLeftAdapter

kotlin 复制代码
class NaviLeftAdapter(private val onItemClick: (position: Int) -> Unit) : BaseQuickAdapter<Navi, BaseViewHolder>(R.layout.item_navi_left) {
    override fun convert(
        holder: BaseViewHolder,
        item: Navi
    ) {
        holder.setText(R.id.text_category, item.name)

        holder.itemView.setOnClickListener {
            onItemClick(getItemPosition(item))
        }
    }

}

NaviRightAdapter

kotlin 复制代码
class NaviRightAdapter(private val onItemClick: (position: Int) -> Unit): BaseQuickAdapter<ArticleX, BaseViewHolder>((R.layout.item_navi_right)) {
    override fun convert(
        holder: BaseViewHolder,
        item: ArticleX
    ) {
        holder.setText(R.id.tv_tag, item.title)
        holder.setTextColor(R.id.tv_tag, randomColor())

        holder.itemView.setOnClickListener {
            onItemClick(getItemPosition(item))
        }
    }
}

NaviFragment

kotlin 复制代码
/**
 * 继承自带 MVP 支持的 BaseMVPFragment,绑定 FragmentNaviBinding 并指定 Presenter 类型
 * ,负责注入 View 与 Presenter 的生命周期管理,符合 MVP 架构最佳实践
 * */
class NaviFragment :
    BaseMVPFragment<FragmentNaviBinding, NaviContract.Presenter>({ FragmentNaviBinding.inflate(it) }),
    NaviContract.View {

    // 导航分类列表数据源,懒加载后赋值
    private lateinit var mNaviList: MutableList<Navi>
    // 当前右侧文章列表数据源,根据选中分类动态赋值
    private lateinit var mArticles: MutableList<ArticleX>

    override fun initView() {
        // 可在此初始化视图组件,如 RecyclerView 分割线、下拉刷新等
    }

    override fun initData() {
        // 在数据初始化阶段请求导航数据,由 Presenter 回调通知 View
        mPresenter.getNavi()
    }

    override fun allClick() {
        // 可在此注册视图点击事件监听,如头部按钮、下拉刷新等
    }

    override fun createPresenter(): NaviContract.Presenter {
        // 将当前 Fragment 作为 View 注入到 Presenter 中,完成 V-P 绑定
        return NaviPresenter(this)
    }

    override fun getNaviSuccess(naviList: MutableList<Navi>) {
        // Presenter 回调获取到导航数据后的处理
        mNaviList = naviList

        // 左侧导航列表 Adapter,点击时更新右侧文章列表
        val naviLeftAdapter = NaviLeftAdapter { pos ->
            mArticles = mNaviList[pos].articles
            setRightLayout(mArticles)
        }

        // 使用 LinearLayoutManager 实现垂直列表布局
        binding.leftNavigation.layoutManager =
            LinearLayoutManager(binding.root.context, LinearLayoutManager.VERTICAL, false)
        binding.leftNavigation.adapter = naviLeftAdapter

        // 将数据提交给 Adapter,触发列表刷新
        naviLeftAdapter.setList(mNaviList)

        // 默认选中第一个分类,展示其相关文章
        mArticles = mNaviList[0].articles
        setRightLayout(mArticles)
    }

    /**
     * 填充右侧数据
     */
    private fun setRightLayout(articles: MutableList<ArticleX>) {
        // 右侧文章列表 Adapter,点击时通过 Intent 跳转到详情页面
        val naviRightAdapter = NaviRightAdapter { pos ->
            val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
                putExtra(DetailActivity.WEB_URL, mArticles[pos].link)
                putExtra(DetailActivity.WEB_TITLE, mArticles[pos].title)
            }
            startActivity(intent)
        }

        // 使用 StaggeredGridLayoutManager 实现两列瀑布流布局
        binding.rightNavi.layoutManager =
            StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
        binding.rightNavi.adapter = naviRightAdapter

        // 将文章列表数据提交给 Adapter,触发布局显示
        naviRightAdapter.setList(articles)
    }

    override fun getNaviError(errorMessage: String) {
        // 网络或业务异常时提示错误信息
        show(errorMessage)
    }

}

至此我们完成了 NaviFragment 界面的编写,是不是越来越得心应手了,发现按照模版去开发,就是简简单单的,界面运行界面如下

接下来我们进行项目界面的编写

3. ProjectFragment 项目界面

ProjectContract

kotlin 复制代码
interface ProjectContract : BaseContract {
    // 定义"项目"模块的契约接口,遵循 MVP 架构,将 Model、View、Presenter 三层职责分离
    interface Model : BaseContract.IBaseModel {
        // Model 层:发起网络请求获取项目列表
        // listener 为自定义的 RetrofitResponseListener,用于回调成功或失败结果
        fun getProject(listener: RetrofitResponseListener<MutableList<Project>>)
    }

    interface View : BaseContract.IBaseView {
        // View 层:请求成功时回调,传递项目列表用于更新 UI
        fun getProjectSuccess(projectList: MutableList<Project>)
        // View 层:请求失败时回调,传递错误信息以便提示用户
        fun getProjectError(errorMessage: String)
    }

    interface Presenter : BaseContract.IBasePresenter {
        // Presenter 层:触发项目列表获取流程,协调 Model 与 View 之间的交互
        fun getProject()
    }
}

ProjectModel

kotlin 复制代码
class ProjectModel : ProjectContract.Model {
    // 实现 ProjectContract.Model 接口,负责项目数据获取逻辑
    override fun getProject(listener: RetrofitResponseListener<MutableList<Project>>) {
        // 使用自定义的 launchCoroutine 扩展函数,在协程中启动异步任务,fire-and-forget 不阻塞调用方
        return launchCoroutine({
            // 通过 RetrofitService 单例获取 ApiService 实例,调用 getProject() 发起网络请求获取项目列表
            val baseProjectBean = RetrofitService.getApiService().getProject()
            // 根据返回的 errorCode 判断请求是否成功,非 0 表示业务错误
            if (baseProjectBean.errorCode != 0) {
                // 请求失败时回调 onError,将错误码与错误信息传递给调用者
                listener.onError(baseProjectBean.errorCode, baseProjectBean.errorMsg)
            } else {
                // 请求成功时回调 onSuccess,将服务端返回的数据列表传递给调用者
                listener.onSuccess(baseProjectBean.data)
            }
        }, onError = { e: Throwable ->
            // 捕获网络或其他异常,并打印堆栈信息以便调试
            e.printStackTrace()
        })
    }

}

ProjectPresenter

kotlin 复制代码
class ProjectPresenter(view: ProjectContract.View) :
// 继承 BasePresenter,绑定 View 与 Model 的通用逻辑,并实现 ProjectContract.Presenter
    BasePresenter<ProjectContract.View, ProjectContract.Model>(view),
    ProjectContract.Presenter {

    // 创建并返回当前 Presenter 需要使用的 Model 实例
    override fun createModel(): ProjectContract.Model = ProjectModel()

    // Presenter 层对外暴露的方法,触发项目列表的获取流程
    override fun getProject() {
        // 在 BasePresenter 提供的 coroutineScope 中启动协程,保证异步操作与生命周期绑定
        coroutineScope.launch {
            // 调用 Model#getProject 发起网络或其他异步请求,并通过回调接收结果
            mModel?.getProject(object : RetrofitResponseListener<MutableList<Project>> {
                // 请求成功时调用,将结果传递给 View 层进行 UI 更新
                override fun onSuccess(response: MutableList<Project>) {
                    mView?.get()?.getProjectSuccess(response)
                }

                // 请求失败时调用,将错误信息传递给 View 层进行提示
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getProjectError(errorMessage)
                }
            })
        }
    }
}

fragment_project.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    tools:context=".presentation.project.ProjectFragment">

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/project_tab_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        app:tabIndicatorColor="@color/colorPrimary"
        app:tabIndicatorFullWidth="false"
        app:tabIndicatorHeight="3dp"
        app:tabMode="scrollable"
        app:tabSelectedTextColor="@color/colorPrimary"
        app:tabTextAppearance="@style/TabLayoutTextStyle"
        app:tabTextColor="@color/gray" />

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/project_view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

ProjectFragment

xml 复制代码
class ProjectFragment : BaseMVPFragment<FragmentProjectBinding, ProjectContract.Presenter>({
    FragmentProjectBinding.inflate(it)
}), ProjectContract.View {
    /**
     * 初始化视图:将 TabLayout 与 ViewPager 绑定,实现滑动时 Tab 自动联动、
     * 点击 Tab 时 ViewPager 自动切换页面。 :contentReference[oaicite:0]{index=0}
     */
    override fun initView() {
        binding.projectTabLayout.setupWithViewPager(binding.projectViewPager)
    }

    /**
     * 初始化数据:通过 Presenter 发起获取项目分类列表的请求 :contentReference[oaicite:1]{index=1}
     */
    override fun initData() {
        mPresenter.getProject()
    }

    /**
     * 注册所有点击事件,这里暂不需要额外处理
     */
    override fun allClick() {
    }

    /**
     * 创建并返回 Presenter 实例,将当前 Fragment 注入给 Presenter
     */
    override fun createPresenter(): ProjectContract.Presenter {
        return ProjectPresenter(this)
    }

    /**
     * Presenter 回调:项目分类数据请求成功
     */
    override fun getProjectSuccess(projectList: MutableList<Project>) {
        // 提取所有分类标题,用于 ViewPager 的页面标签
        val titles: MutableList<String> = ArrayList()
        for (project in projectList) {
            titles.add(project.name)
        }

        // 创建通用的 ViewPager 适配器,并传入子 FragmentManager
        // childFragmentManager 用于管理 Fragment 中的子 Fragment :contentReference[oaicite:2]{index=2}
        val commonViewPagerAdapter = CommonViewPageAdapter(childFragmentManager, titles)

        // 根据每个分类 ID 构造对应的 ProjectChildFragment 实例,添加到适配器
        for (project in projectList) {
            commonViewPagerAdapter.addFragment(
                ProjectChildFragment.newInstance(project.id)
            )
        }

        // 将适配器绑定到 ViewPager 上,并默认选中第一个页面
        binding.projectViewPager.adapter = commonViewPagerAdapter
        binding.projectViewPager.currentItem = 0
    }

    /**
     * Presenter 回调:项目分类数据请求失败,显示错误提示
     */
    override fun getProjectError(errorMessage: String) {
        show(errorMessage)
    }

}

fragment_project_child.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".presentation.project.child.ProjectChildFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/item_article" />


</FrameLayout>

ProjectChildFragment

kotlin 复制代码
class ProjectChildFragment :
    BaseMVPFragment<FragmentProjectChildBinding, ProjectChildContract.Presenter>({
        FragmentProjectChildBinding.inflate(it)
    }), ProjectChildContract.View, OnLoadMoreListener,
    OnItemClickListener {

    companion object {
        private const val TOTAL_COUNTER = 20   // 每次加载的数据量
        private var CURRENT_SIZE = 0          // 当前已加载的数据数量,用于判断是否还有更多
        private var CURRENT_PAGE = 1          // 当前请求的页码,分页加载时递增

        const val CID: String = "cid"         // Fragment 参数 key,用于传递分类 ID

        /**
         * 创建 Fragment 的静态工厂方法,推荐使用 newInstance 模式,
         * 将参数封装到 Bundle 并通过 setArguments 传递,保证重建时参数不丢失
         */
        fun newInstance(cid: Int): ProjectChildFragment {
            val projectChildFragment = ProjectChildFragment()
            val bundle = Bundle().apply {
                putInt(CID, cid)            // 将分类 ID 放入 Bundle
            }
            projectChildFragment.arguments = bundle
            return projectChildFragment
        }
    }

    private var mCid: Int = 0                      // 存储当前分类 ID
    private lateinit var mDataList: MutableList<DataX>   // 文章数据列表,用于 Adapter 数据源
    private lateinit var mProjectChildAdapter: ProjectChildAdapter  // RecyclerView 的 Adapter

    override fun createPresenter(): ProjectChildContract.Presenter {
        // 创建并绑定 Presenter 实例,将当前 Fragment 作为 View 注入
        return ProjectChildPresenter(this)
    }

    override fun initView() {
        // 给 RecyclerView 添加分割线装饰,实现垂直列表间隔
        binding.recyclerView.addItemDecoration(
            DividerItemDecoration(
                binding.root.context,
                LinearLayoutManager.VERTICAL
            )
        )
    }

    override fun initData() {
        // 从 arguments 获取传入的分类 ID,并调用 Presenter 发起首页数据请求
        mCid = arguments?.getInt(CID)!!
        mPresenter.getProjectChild(CURRENT_PAGE, mCid)
    }

    override fun allClick() {
        // 如需处理其他点击事件,可在此注册监听
    }

    override fun onLoadMore() {
        // 上拉加载更多回调:根据已加载数量判断是否需要结束加载或请求下一页
        if (CURRENT_SIZE < TOTAL_COUNTER) {
            // 数据不足一页,提示没有更多,并结束加载状态
            mProjectChildAdapter.loadMoreModule.loadMoreEnd(true)
        } else {
            // 增加页码并请求下一页数据
            CURRENT_PAGE++
            mPresenter.getProjectMoreChild(CURRENT_PAGE, mCid)
        }
    }

    override fun onItemClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
        // 列表项点击回调,通过 Intent 跳转到详情页面,并传递 URL 和标题参数
        val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
            putExtra(DetailActivity.WEB_URL, mDataList[position].link)
            putExtra(DetailActivity.WEB_TITLE, mDataList[position].title)
        }
        startActivity(intent)
    }

    override fun getProjectChildSuccess(projectChild: ProjectChild) {
        // 首次数据请求成功:更新当前加载数量与数据列表,并初始化 Adapter
        CURRENT_SIZE = projectChild.datas.size
        mDataList = projectChild.datas

        mProjectChildAdapter = ProjectChildAdapter().apply {
            setOnItemClickListener(this@ProjectChildFragment)  // 设置条目点击监听
            loadMoreModule.setOnLoadMoreListener(this@ProjectChildFragment)  // 设置加载更多监听
        }
        binding.recyclerView.adapter = mProjectChildAdapter
        mProjectChildAdapter.setList(mDataList)  // 提交数据刷新列表
    }

    override fun getProjectChildError(errorMessage: String) {
        // 数据请求失败时在 UI 上提示错误信息
        show(errorMessage)
    }

    override fun getProjectMoreChildSuccess(projectChild: ProjectChild) {
        // 分页加载更多成功:更新加载数量,追加数据,并结束加载状态
        CURRENT_SIZE = projectChild.datas.size
        mDataList.addAll(projectChild.datas)
        mProjectChildAdapter.addData(projectChild.datas)
        mProjectChildAdapter.loadMoreModule.loadMoreComplete()
    }

    override fun getProjectMoreChildError(errorMessage: String) {
        // 分页加载失败时在 UI 上提示错误信息
        show(errorMessage)
    }

}

ProjectChildContract

kotlin 复制代码
interface ProjectChildContract : BaseContract {
    // 定义"项目子项"模块的契约接口,遵循 MVP 架构,将 Model、View、Presenter 三层职责分离 
    interface Model : BaseContract.IBaseModel {
        /**
         * 获取指定分类(cid)下的项目列表(第 page 页)
         * @param page 页码,从 1 开始
         * @param cid  分类 ID,用于区分不同项目分类
         * @param listener 回调接口,成功时返回 ProjectChild 对象,失败时返回错误信息 
         */
        fun getProjectChild(
            page: Int,
            cid: Int,
            listener: RetrofitResponseListener<ProjectChild>
        )

        /**
         * 加载更多指定分类(cid)的后续页面数据
         * 与 getProjectChild 区别仅在于调用时机:通常用于上拉分页时请求下一页 
         */
        fun getProjectMoreChild(
            page: Int,
            cid: Int,
            listener: RetrofitResponseListener<ProjectChild>
        )
    }

    interface View : BaseContract.IBaseView {
        /**
         * 获取子项目列表成功回调
         * @param projectChild 包含列表数据及分页信息的实体类 
         */
        fun getProjectChildSuccess(projectChild: ProjectChild)

        /**
         * 获取子项目列表失败回调
         * @param errorMessage 错误消息,供界面提示或日志记录 
         */
        fun getProjectChildError(errorMessage: String)

        /**
         * 加载更多子项目成功回调
         * @param projectChild 包含加载到的新数据及分页信息 
         */
        fun getProjectMoreChildSuccess(projectChild: ProjectChild)

        /**
         * 加载更多子项目失败回调
         * @param errorMessage 错误消息,供界面提示或日志记录 
         */
        fun getProjectMoreChildError(errorMessage: String)
    }

    interface Presenter : BaseContract.IBasePresenter {
        /**
         * 请求指定分类的第 page 页子项目列表
         * Presenter 层负责调用 Model#getProjectChild,并将结果分发给 View 
         */
        fun getProjectChild(page: Int, cid: Int)

        /**
         * 请求更多指定分类的子项目下一页数据
         * 用于分页加载场景,调用 Model#getProjectMoreChild 并分发结果 
         */
        fun getProjectMoreChild(page: Int, cid: Int)
    }
}

ProjectChildModel

kotlin 复制代码
class ProjectChildModel : ProjectChildContract.Model {
    // 实现 Model 接口:用于获取指定分类 page 页的项目子项列表
    override fun getProjectChild(
        page: Int,
        cid: Int,
        listener: RetrofitResponseListener<ProjectChild>
    ) {
        // 在自定义的 launchCoroutine 中执行网络请求,自动处理协程上下文与异常
        return launchCoroutine({
            // 发起 Retrofit 网络请求,挂起函数 getProjectChild(page, cid)
            val projectChildBaseBean =
                RetrofitService.getApiService().getProjectChild(page, cid)
            // 根据统一 API 响应中的 errorCode 判断请求结果
            if (projectChildBaseBean.errorCode != 0) {
                // 业务错误时回调 onError,传递错误码与信息给 Presenter
                listener.onError(projectChildBaseBean.errorCode, projectChildBaseBean.errorMsg)
            } else {
                // 请求成功时回调 onSuccess,传递解析后的数据
                listener.onSuccess(projectChildBaseBean.data)
            }
        }, onError = { e: Throwable ->
            // 捕获网络或其他异常,打印堆栈以便调试
            e.printStackTrace()
        })
    }

    // 实现加载更多接口,逻辑与 getProjectChild 相同,仅调用时机不同(分页加载)
    override fun getProjectMoreChild(
        page: Int,
        cid: Int,
        listener: RetrofitResponseListener<ProjectChild>
    ) {
        return launchCoroutine({
            val projectChildBaseBean =
                RetrofitService.getApiService().getProjectChild(page, cid)
            if (projectChildBaseBean.errorCode != 0) {
                listener.onError(projectChildBaseBean.errorCode, projectChildBaseBean.errorMsg)
            } else {
                listener.onSuccess(projectChildBaseBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }
}

ProjectChildPresenter

kotlin 复制代码
class ProjectChildPresenter(view: ProjectChildContract.View) :
// 继承自 BasePresenter,绑定 View 与 Model 的通用逻辑,并实现 ProjectChildContract.Presenter 接口
    BasePresenter<ProjectChildContract.View, ProjectChildContract.Model>(view),
    ProjectChildContract.Presenter {

    /**
     * 创建并返回当前 Presenter 对应的 Model 实例
     */
    override fun createModel(): ProjectChildContract.Model {
        return ProjectChildModel()
    }

    /**
     * 请求指定分类(cid)第 page 页的子项目列表
     * Presenter 调用 Model#getProjectChild,并在回调中将结果分发给 View
     */
    override fun getProjectChild(page: Int, cid: Int) {
        // 使用 BasePresenter 提供的 coroutineScope 启动协程,保证与生命周期绑定
        coroutineScope.launch {
            mModel?.getProjectChild(page, cid, object : RetrofitResponseListener<ProjectChild> {
                /**
                 * 网络请求成功时调用,将数据传递给 View 进行显示
                 */
                override fun onSuccess(response: ProjectChild) {
                    mView?.get()?.getProjectChildSuccess(response)
                }

                /**
                 * 网络请求或业务错误时调用,将错误信息传递给 View 进行提示
                 */
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getProjectChildError(errorMessage)
                }

            })
        }
    }

    /**
     * 请求指定分类(cid)第 page 页的更多子项目(用于分页加载下一页)
     * 与 getProjectChild 不同的是,回调分为 "加载更多" 逻辑
     */
    override fun getProjectMoreChild(page: Int, cid: Int) {
        // 同样在协程中异步请求
        coroutineScope.launch {
            mModel?.getProjectMoreChild(page, cid, object : RetrofitResponseListener<ProjectChild> {
                /**
                 * 加载更多成功时调用,将新数据传递给 View 追加显示
                 */
                override fun onSuccess(response: ProjectChild) {
                    mView?.get()?.getProjectMoreChildSuccess(response)
                }

                /**
                 * 加载更多失败时调用,将错误信息传递给 View 进行提示
                 */
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getProjectMoreChildError(errorMessage)
                }

            })
        }
    }

}

item_project_child.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/white"
    android:foreground="?android:attr/selectableItemBackground"
    android:orientation="horizontal"
    android:padding="@dimen/dp_20">

    <ImageView
        android:id="@+id/iv_project_img"
        android:layout_width="@dimen/dp_80"
        android:layout_height="wrap_content"
        android:layout_marginRight="@dimen/dp_15"
        android:contentDescription="@string/app_name"
        android:src="@mipmap/ic_launcher" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tv_project_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="标题"
            android:textColor="@color/black"
            android:textSize="@dimen/sp_16"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/tv_project_desc"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="@dimen/dp_10"
            android:layout_weight="1"
            android:ellipsize="end"
            android:maxLines="3"
            android:text="标题"
            android:textColor="@color/gray" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_10"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/tv_project_date"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="标题"
                android:textColor="@color/gray" />

            <TextView
                android:id="@+id/tv_project_author"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="标题"
                android:textColor="@color/gray" />

        </LinearLayout>

    </LinearLayout>


</LinearLayout>

ProjectChildAdapter

kotlin 复制代码
class ProjectChildAdapter : BaseQuickAdapter<DataX, BaseViewHolder>(R.layout.item_project_child),
    LoadMoreModule {
    override fun convert(holder: BaseViewHolder, item: DataX) {
        Glide.with(context).load(item.envelopePic).into(holder.getView(R.id.iv_project_img))
        holder.setText(R.id.tv_project_title, item.title)
        holder.setText(R.id.tv_project_desc, item.desc)
        holder.setText(R.id.tv_project_date, item.niceDate)
        holder.setText(R.id.tv_project_author, item.author)
    }
}

至此,项目界面就完成了,卸载运行结果如下所示

至此我们主要界面都设计完成了,那么还有一些小功能还没有实现,例如退出登录,查看收藏这些,我们应该如何去设计呢?是在底部导航栏创建一个类似个人中心的标签吗?这确实是一种方法,那我们还有别的方法吗?我们可以使用侧边导航栏的效果去实现。

4. 优化界面

我们现在 activity_main.xml 中,将 toolbar viewpager + BottomNavigationView 写在一起了,其实可以分离开,以便日后修改,所以我们将 viewpager + BottomNavigationView 抽到单独的布局中来

content_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".presentation.main.MainActivity"
    tools:showIn="@layout/app_bar_main">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/view_pager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottom_navigation"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="?android:attr/windowBackground"
            app:menu="@menu/bottom_navigation" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

然后再将其和 toolbar 结合一起放在一个布局中

app_bar_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.main.MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>

    <include
        android:id="@+id/include_content_main"
        layout="@layout/content_main" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

因为我们要是使用侧边抽屉的方式来显示个人信息和收藏等相关入口,所以修改 activity_main.xml 如下

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="start"
    tools:context=".presentation.main.MainActivity">

    <include
        android:id="@+id/include_main"
        layout="@layout/app_bar_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />


    <com.google.android.material.navigation.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true"
        app:headerLayout="@layout/nav_header_main"
        app:menu="@menu/activity_main_drawer" />


</androidx.drawerlayout.widget.DrawerLayout>

DrawerLayout 是 AndroidX 提供的一个可将侧边抽屉式导航面板与主内容区结合的顶层容器,通过手势或菜单图标滑出侧栏

NavigationView 是 Material Components 提供的一个专用导航菜单视图,继承自 FrameLayout,可通过菜单资源文件快速生成符合 Material Design 规范的侧边导航列表

在根布局使用 DrawerLayout,主内容区与 NavigationView 并列,NavigationView 设置 layout_gravity="start"

侧边栏头部信息 nav_header_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="@dimen/nav_header_height"
    android:background="@drawable/side_nav_bar"
    android:gravity="bottom"
    android:orientation="vertical"
    android:paddingLeft="@dimen/dp_16"
    android:paddingTop="@dimen/dp_26"
    android:paddingRight="@dimen/dp_16"
    android:paddingBottom="@dimen/dp_16"
    android:theme="@style/ThemeOverlay.AppCompat.Dark">

    <ImageView
        android:id="@+id/iv_user_icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@null"
        android:paddingTop="@dimen/nav_header_vertical_spacing"
        app:srcCompat="@mipmap/ic_launcher_round" />

    <TextView
        android:id="@+id/tv_username"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingTop="@dimen/nav_header_vertical_spacing"
        android:text="username"
        android:textAppearance="@style/TextAppearance.AppCompat.Body1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="玩安卓Kotlin语言MVP版" />
</LinearLayout>

侧边栏功能列表入口

activity_main_drawer.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:showIn="navigation_view">

    <group android:checkableBehavior="single">
        <item
            android:id="@+id/nav_collect"
            android:icon="@drawable/ic_menu_favorite"
            android:title="我的收藏" />
        <item
            android:id="@+id/nav_share"
            android:icon="@drawable/ic_menu_share"
            android:title="我要分享" />
        <item
            android:id="@+id/nav_about"
            android:icon="@drawable/ic_menu_info"
            android:title="关于我们" />
    </group>

    <item android:title="二级菜单 ">
        <menu>
            <item
                android:id="@+id/nav_logout"
                android:icon="@drawable/ic_menu_exit"
                android:title="退出登录" />
        </menu>
    </item>

</menu>

修改 MainActivity

kotlin 复制代码
package com.example.studymvpexampleapplication.presentation.main

import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.TextView
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.viewpager.widget.ViewPager.OnPageChangeListener
import com.example.studymvpexampleapplication.R
import com.example.studymvpexampleapplication.base.BaseActivity
import com.example.studymvpexampleapplication.databinding.ActivityMainBinding
import com.example.studymvpexampleapplication.presentation.home.HomeFragment
import com.example.studymvpexampleapplication.presentation.navi.NaviFragment
import com.example.studymvpexampleapplication.presentation.project.ProjectFragment
import com.example.studymvpexampleapplication.presentation.tree.TreeFragment
import androidx.core.view.get
import androidx.viewpager.widget.ViewPager
import com.example.studymvpexampleapplication.common.MyConfig
import com.example.studymvpexampleapplication.presentation.about.AboutActivity
import com.example.studymvpexampleapplication.presentation.collect.CollectActivity
import com.example.studymvpexampleapplication.presentation.login.LoginActivity
import com.yechaoa.yutilskt.ActivityUtil
import com.yechaoa.yutilskt.SpUtil
import com.yechaoa.yutilskt.show

class MainActivity :
// 继承项目通用 BaseActivity,绑定 ActivityMainBinding 布局
    BaseActivity<ActivityMainBinding>({ ActivityMainBinding.inflate(it) }) {

    // 视图初始化,在这里设置 Toolbar、侧边栏头部、抽屉与 Fragment
    override fun initView() {
        // 设置 Toolbar 标题
        binding.includeMain.toolbar.title = getString(R.string.app_name)
        // 从 SharedPreferences 获取用户名并显示在 NavigationView 的头部
        binding.navView
            .getHeaderView(0)
            .findViewById<TextView>(R.id.tv_username)
            .text = SpUtil.getString(MyConfig.USER_NAME, "username")
        // 将布局中的 Toolbar 绑定为 Activity 的 ActionBar
        setSupportActionBar(binding.includeMain.toolbar)
        // 初始化 DrawerLayout 与 Toolbar 的联动
        initActionBarDrawer()
        // 初始化底部 ViewPager 与其 Fragment 列表
        initFragments()
    }

    // 数据初始化,此处暂无业务逻辑
    override fun initData() {
    }

    // 统一注册点击与滑动事件
    override fun allClick() {
        /*** 侧边栏菜单点击事件 ***/
        binding.navView.setNavigationItemSelectedListener {
            when (it.itemId) {
                R.id.nav_collect -> {
                    // 跳转到收藏页
                    startActivity(Intent(this, CollectActivity::class.java))
                }
                R.id.nav_share -> {
                    // 调用系统分享
                    shareProject()
                }
                R.id.nav_about -> {
                    // 跳转到关于页
                    startActivity(Intent(this, AboutActivity::class.java))
                }
                R.id.nav_logout -> {
                    // 退出登录弹窗提示
                    AlertDialog.Builder(this@MainActivity).apply {
                        setTitle("提示")
                        setMessage("确定退出登录吗?")
                        setPositiveButton("确定") { _, _ ->
                            // 清除登录状态与用户信息,跳转到登录页并关闭当前
                            SpUtil.setBoolean(MyConfig.IS_LOGIN, false)
                            SpUtil.removeByKey(MyConfig.COOKIE)
                            SpUtil.removeByKey(MyConfig.USER_NAME)
                            startActivity(Intent(this@MainActivity, LoginActivity::class.java))
                            finish()
                        }
                        setNegativeButton("取消", null)
                    }.create().show()
                }
            }
            // 关闭侧边栏
            binding.drawerLayout.closeDrawer(GravityCompat.START)
            true
        }

        /*** ViewPager 滑动监听:同步底部导航栏与 Toolbar 标题 ***/
        binding.includeMain.includeContentMain.viewPager
            .addOnPageChangeListener(object : OnPageChangeListener {
                override fun onPageScrolled(
                    position: Int,
                    positionOffset: Float,
                    positionOffsetPixels: Int
                ) {
                    // 根据当前页位置,更新 BottomNavigationView 的选中状态
                    binding.includeMain.includeContentMain.bottomNavigation
                        .menu.getItem(position).isChecked = true
                    // 更新 Toolbar 标题(setChecked 不会触发点击事件)
                    binding.includeMain.toolbar.title = when (position) {
                        0 -> getString(R.string.app_name)
                        1 -> getString(R.string.title_tree)
                        2 -> getString(R.string.title_navi)
                        else -> getString(R.string.title_project)
                    }
                }

                override fun onPageSelected(position: Int) {
                    // 页面完全切换时的回调,可用于统计或动画
                }

                override fun onPageScrollStateChanged(state: Int) {
                    // 滚动状态改变时的回调
                }
            })

        /*** BottomNavigationView 点击事件:切换 ViewPager ***/
        binding.includeMain.includeContentMain.bottomNavigation
            .setOnNavigationItemSelectedListener {
                when (it.itemId) {
                    R.id.navigation_home -> {
                        binding.includeMain.includeContentMain.viewPager.currentItem = 0
                        return@setOnNavigationItemSelectedListener true
                    }
                    R.id.navigation_tree -> {
                        binding.includeMain.includeContentMain.viewPager.currentItem = 1
                        return@setOnNavigationItemSelectedListener true
                    }
                    R.id.navigation_navi -> {
                        binding.includeMain.includeContentMain.viewPager.currentItem = 2
                        return@setOnNavigationItemSelectedListener true
                    }
                    R.id.navigation_project -> {
                        binding.includeMain.includeContentMain.viewPager.currentItem = 3
                        return@setOnNavigationItemSelectedListener true
                    }
                }
                false
            }
    }

    /**
     * 调用系统分享功能,分享项目 GitHub 地址
     */
    private fun shareProject() {
        Intent().apply {
            action = Intent.ACTION_SEND
            type = "text/plain"
            putExtra(Intent.EXTRA_SUBJECT, "玩安卓")
            putExtra(Intent.EXTRA_TEXT, "https://blog.csdn.net/m0_56334538/article/details/147562633?spm=1011.2124.3001.6209")
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }.also {
            startActivity(Intent.createChooser(it, "玩安卓"))
        }
    }

    /**
     * 配置 DrawerLayout 与 Toolbar 的汉堡按钮联动
     */
    private fun initActionBarDrawer() {
        val toggle = ActionBarDrawerToggle(
            this,
            binding.drawerLayout,
            binding.includeMain.toolbar,
            R.string.navigation_drawer_open,
            R.string.navigation_drawer_close
        )
        // 添加开关监听并同步状态
        binding.drawerLayout.addDrawerListener(toggle)
        toggle.syncState()
    }

    private var mExitTime: Long = 0 // 记录上次点击返回键的时间戳

    /**
     * 拦截返回键:侧边栏打开时先关闭,未打开时双击退出
     */
    @SuppressLint("MissingSuperCall")
    override fun onBackPressed() {
        if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
            // 如果侧边栏处于打开状态,则先关闭它
            binding.drawerLayout.closeDrawer(GravityCompat.START)
        } else {
            // 判断两次返回间隔,小于2秒则退出应用
            if (System.currentTimeMillis() - mExitTime > 2000) {
                show("再按一次退出")
                mExitTime = System.currentTimeMillis()
            } else {
                // 关闭所有 Activity
                ActivityUtil.closeAllActivity()
            }
        }
    }

    /**
     * 初始化 ViewPager 并添加四个主页面 Fragment
     */
    private fun initFragments() {
        val viewPagerAdapter = CommonViewPageAdapter(supportFragmentManager).apply {
            addFragment(HomeFragment())     // 首页
            addFragment(TreeFragment())     // 体系
            addFragment(NaviFragment())     // 导航
            addFragment(ProjectFragment())  // 项目
        }
        // 设置预加载页面数量,左右各保留最多3个 Fragment
        binding.includeMain.includeContentMain.viewPager.offscreenPageLimit = 3
        // 绑定适配器到 ViewPager,实现滑动切换
        binding.includeMain.includeContentMain.viewPager.adapter = viewPagerAdapter
    }
}

CollectContract

kotlin 复制代码
interface CollectContract : BaseContract {
    // Model 层:负责收藏数据的获取与操作
    interface Model : BaseContract.IBaseModel {
        /**
         * 获取收藏列表
         * @param page 分页页码,从 0 开始
         * @param listener 回调接口,成功返回 Collect 实体,失败返回错误信息
         */
        suspend fun getCollectList(page: Int, listener: RetrofitResponseListener<Collect>)

        /**
         * 获取更多收藏列表(分页加载下一页)
         * @param page 下一页页码
         * @param listener 回调接口,成功返回 Collect 实体,失败返回错误信息
         */
        suspend fun getCollectMoreList(page: Int, listener: RetrofitResponseListener<Collect>)

        /**
         * 取消收藏
         * @param id 收藏记录的唯一 ID
         * @param originId 原始文章 ID(无原始文章时传 -1)
         * @param listener 回调接口,成功返回操作结果消息,失败返回错误信息
         */
        suspend fun unCollect(id: Int, originId: Int, listener: RetrofitResponseListener<String>)
    }

    // View 层:定义 UI 层显示与错误提示的接口
    interface View : BaseContract.IBaseView {
        /**
         * 取消收藏成功回调
         * @param successMessage 成功提示信息
         */
        fun unCollectSuccess(successMessage: String)

        /**
         * 取消收藏失败回调
         * @param errorMessage 错误提示信息
         */
        fun unCollectError(errorMessage: String)

        /**
         * 获取收藏列表成功回调
         * @param collect 收藏列表数据实体
         */
        fun getCollectListSuccess(collect: Collect)

        /**
         * 获取收藏列表失败回调
         * @param errorMessage 错误提示信息
         */
        fun getCollectListError(errorMessage: String)

        /**
         * 加载更多收藏列表成功回调
         * @param collect 收藏列表数据实体
         */
        fun getCollectMoreListSuccess(collect: Collect)

        /**
         * 加载更多收藏列表失败回调
         * @param errorMessage 错误提示信息
         */
        fun getCollectMoreListError(errorMessage: String)

        /**
         * 未登录回调
         * @param msg 提示登录的消息
         */
        fun login(msg: String)
    }

    // Presenter 层:负责协调 Model 与 View,触发数据请求并分发结果
    interface Presenter : BaseContract.IBasePresenter {
        /**
         * 触发取消收藏操作
         * @param id 收藏记录的唯一 ID
         * @param originId 原始文章 ID
         */
        fun unCollect(id: Int, originId: Int)

        /**
         * 触发获取收藏列表
         * @param page 分页页码
         */
        fun getCollectList(page: Int)

        /**
         * 触发分页加载更多收藏列表
         * @param page 下一页页码
         */
        fun getCollectMoreList(page: Int)
    }
}

CollectModel

kotlin 复制代码
class CollectModel : CollectContract.Model {
    /**
     * 获取收藏列表(第一页或指定页)
     * @param page 请求页码,从 0 开始
     * @param listener 回调接口,返回 Collect 实体或错误信息
     */
    override suspend fun getCollectList(page: Int, listener: RetrofitResponseListener<Collect>) {
        // 在协程中执行网络请求,保证不阻塞 UI 线程
        return launchCoroutine({
            // 调用 RetrofitService 获取收藏列表数据
            val collectBaseBean = RetrofitService.getApiService().getCollectList(page)
            // 判断返回码,非 0 视为业务错误
            if (collectBaseBean.errorCode != 0) {
                // 请求失败,回调错误码和错误信息
                listener.onError(collectBaseBean.errorCode, collectBaseBean.errorMsg)
            } else {
                // 请求成功,回调实际数据
                listener.onSuccess(collectBaseBean.data)
            }
        }, onError = { e: Throwable ->
            // 捕获网络或其他异常,打印堆栈以便调试
            e.printStackTrace()
        })
    }

    /**
     * 获取更多收藏列表(下一页)
     * @param page 请求的下一页页码
     * @param listener 回调接口
     */
    override suspend fun getCollectMoreList(
        page: Int,
        listener: RetrofitResponseListener<Collect>
    ) {
        // 与 getCollectList 逻辑相同,仅请求页码不同
        return launchCoroutine({
            val collectBaseBean = RetrofitService.getApiService().getCollectList(page)
            if (collectBaseBean.errorCode != 0) {
                listener.onError(collectBaseBean.errorCode, collectBaseBean.errorMsg)
            } else {
                listener.onSuccess(collectBaseBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 取消收藏(根据收藏 ID 和原始文章 ID)
     * @param id 收藏记录的唯一标识
     * @param originId 原始文章 ID;如果为自己收藏页面的取消操作,则为 -1
     * @param listener 回调接口,成功返回固定提示,失败返回错误信息
     */
    override suspend fun unCollect(
        id: Int,
        originId: Int,
        listener: RetrofitResponseListener<String>
    ) {
        // 发起取消收藏请求
        return launchCoroutine({
            val baseCollectBean = RetrofitService.getApiService().unCollect1(id, originId)
            if (baseCollectBean.errorCode != 0) {
                // 业务错误时回调错误信息
                listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
            } else {
                // 成功时回调固定成功提示
                listener.onSuccess("取消收藏成功")
            }
        }, onError = { e: Throwable ->
            // 异常时打印日志
            e.printStackTrace()
        })
    }
}

CollectPresenter

kotlin 复制代码
class CollectPresenter(view: CollectContract.View) :
// 继承自 BasePresenter,绑定 View 与 Model 的通用逻辑,并实现 CollectContract.Presenter 接口
    BasePresenter<CollectContract.View, CollectContract.Model>(view),
    CollectContract.Presenter {

    /**
     * 创建并返回当前 Presenter 对应的 Model 实例
     */
    override fun createModel(): CollectContract.Model {
        return CollectModel()
    }

    /**
     * 取消收藏
     * @param id 收藏记录的唯一 ID
     * @param originId 原始文章 ID;当在"我的收藏"页面取消时,可为 -1
     */
    override fun unCollect(id: Int, originId: Int) {
        // 在 BasePresenter 提供的 coroutineScope 中启动协程,保证生命周期安全
        coroutineScope.launch {
            // 调用 Model 层的 unCollect 方法,并通过 RetrofitResponseListener 回调结果
            mModel?.unCollect(id, originId, object : RetrofitResponseListener<String> {
                // 请求成功时调用,将成功消息传递给 View 层
                override fun onSuccess(response: String) {
                    mView?.get()?.unCollectSuccess(response)
                }

                // 请求失败时调用,将错误信息传递给 View 层
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.unCollectError(errorMessage)
                }
            })
        }
    }

    /**
     * 获取收藏列表(首页或指定页)
     * @param page 分页页码,从 0 开始
     */
    override fun getCollectList(page: Int) {
        coroutineScope.launch {
            // 调用 Model 层获取收藏列表并监听回调
            mModel?.getCollectList(page, object : RetrofitResponseListener<Collect> {
                // 请求成功时,传递数据给 View 更新 UI
                override fun onSuccess(response: Collect) {
                    mView?.get()?.getCollectListSuccess(response)
                }
                // 请求失败时,根据错误码判断是否需要跳转登录或显示错误
                override fun onError(errorCode: Int, errorMessage: String) {
                    if (errorCode == -1001) {
                        // 特殊错误码表示未登录,通知 View 执行登录流程
                        mView?.get()?.login(errorMessage)
                    } else {
                        // 其他错误,通知 View 显示错误信息
                        mView?.get()?.getCollectListError(errorMessage)
                    }
                }
            })
        }
    }

    /**
     * 分页加载更多收藏列表
     * @param page 下一页页码
     */
    override fun getCollectMoreList(page: Int) {
        coroutineScope.launch {
            // 调用 Model 层获取下一页数据
            mModel?.getCollectMoreList(page, object : RetrofitResponseListener<Collect> {
                // 成功时通知 View 追加数据
                override fun onSuccess(response: Collect) {
                    mView?.get()?.getCollectMoreListSuccess(response)
                }
                // 失败时通知 View 显示错误
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getCollectMoreListError(errorMessage)
                }
            })
        }
    }
}

activity_collect.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/swipe_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.collect.CollectActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/item_article" />

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

CollectAdapter

kotlin 复制代码
class CollectAdapter : BaseQuickAdapter<CollectDetail, BaseViewHolder>(R.layout.item_article),
    LoadMoreModule {

    init {
        addChildClickViewIds(R.id.article_favorite)
    }

    override fun convert(holder: BaseViewHolder, item: CollectDetail) {
        //fromHtml,因为搜索结果中的title中含有html标签
        holder.setText(R.id.article_title, Html.fromHtml(item.title))
        holder.setText(R.id.article_chapter, item.chapterName)
        holder.setText(R.id.article_date, item.niceDate)
        Glide.with(context).load(R.drawable.ic_like_checked)
            .into(holder.getView(R.id.article_favorite))
    }
}

CollectActivity

kotlin 复制代码
/**
 * 收藏列表 Activity
 * 继承 BaseMVPActivity,使用 MVP 架构管理业务逻辑与 UI
 * 实现了列表点击、分页加载、下拉刷新和收藏取消等功能
 */
class CollectActivity : BaseMVPActivity<ActivityCollectBinding, CollectContract.Presenter>(
    { ActivityCollectBinding.inflate(it) }
), CollectContract.View,
    OnItemClickListener,          // 列表项点击监听
    OnLoadMoreListener,           // 列表加载更多监听
    SwipeRefreshLayout.OnRefreshListener,  // 下拉刷新监听
    OnItemChildClickListener {    // 列表子项点击监听(用于取消收藏)

    // Adapter 与数据源
    private lateinit var mCollectAdapter: CollectAdapter
    private lateinit var mDataList: MutableList<CollectDetail>
    private var mPosition: Int = 0   // 记录当前操作的条目位置

    companion object {
        private const val TOTAL_COUNTER = 20 // 每页加载数量
        private var CURRENT_SIZE = 0         // 当前已加载数量
        private var CURRENT_PAGE = 0         // 当前页码
    }

    /**
     * 视图初始化:设置标题、返回按钮、下拉刷新并请求首页数据
     */
    override fun initView() {
        setBarTitle("收藏列表")        // 设置标题
        setBackEnabled()               // 启用返回按钮
        initSwipeRefreshLayout()       // 初始化 SwipeRefreshLayout 样式与监听
        mPresenter.getCollectList(CURRENT_PAGE) // 请求第一页收藏列表
    }

    // 数据初始化:无额外逻辑
    override fun initData() {}

    // 事件注册:无额外逻辑
    override fun allClick() {}

    // 绑定 Presenter
    override fun createPresenter(): CollectContract.Presenter {
        return CollectPresenter(this)
    }

    // 返回当前 Activity 用于 Presenter 内部使用
    override fun getActivity(): Activity {
        return this
    }

    /**
     * 列表项点击:跳转到详情页面
     */
    override fun onItemClick(
        adapter: BaseQuickAdapter<*, *>, view: View, position: Int
    ) {
        val detail = mDataList[position]
        Intent(this, DetailActivity::class.java).apply {
            putExtra(DetailActivity.WEB_TITLE, detail.title)
            putExtra(DetailActivity.WEB_URL, detail.link)
        }.also { startActivity(it) }
    }

    /**
     * 上拉加载更多:根据已加载数量判断是否还有更多或请求下一页
     */
    override fun onLoadMore() {
        if (CURRENT_SIZE < TOTAL_COUNTER) {
            // 数据未满一页,结束加载,显示无更多提示
            mCollectAdapter.loadMoreModule.loadMoreEnd(true)
        } else {
            // 请求下一页数据
            CURRENT_PAGE++
            mPresenter.getCollectMoreList(CURRENT_PAGE)
        }
    }

    /**
     * 下拉刷新:重置页码并重新加载首 页
     */
    override fun onRefresh() {
        binding.swipeRefresh.postDelayed({
            CURRENT_PAGE = 0
            mPresenter.getCollectList(CURRENT_PAGE)
            binding.swipeRefresh.isRefreshing = false
        }, 1500)
    }

    /**
     * 子项按钮点击(取消收藏):记录位置并调用 Presenter 取消收藏
     */
    override fun onItemChildClick(
        adapter: BaseQuickAdapter<*, *>, view: View, position: Int
    ) {
        mPosition = position
        // originId 用于区分是否有原始文章 ID
        val originId = mDataList[position].originId.takeIf { it > 0 } ?: -1
        mPresenter.unCollect(mDataList[position].id, originId)
    }

    /**
     * 取消收藏成功:移除列表项并提示
     */
    override fun unCollectSuccess(successMessage: String) {
        show(successMessage)
        mCollectAdapter.removeAt(mPosition)
    }

    /**
     * 取消收藏失败:提示错误信息
     */
    override fun unCollectError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 获取收藏列表成功:初始化 Adapter 并展示数据
     */
    override fun getCollectListSuccess(collect: Collect) {
        CURRENT_SIZE = collect.datas.size
        mDataList = collect.datas
        // 初始化 Adapter,配置动画、点击与加载更多
        mCollectAdapter = CollectAdapter().apply {
            animationEnable = true
            setOnItemClickListener(this@CollectActivity)
            setOnItemChildClickListener(this@CollectActivity)
            loadMoreModule.setOnLoadMoreListener(this@CollectActivity)
        }
        // RecyclerView 布局与 Adapter 绑定
        binding.recyclerView.apply {
            layoutManager = LinearLayoutManager(context)
            addItemDecoration(
                DividerItemDecoration(context, LinearLayoutManager.VERTICAL)
            )
            adapter = mCollectAdapter
        }
        mCollectAdapter.setList(mDataList)
    }

    /**
     * 获取收藏列表失败:提示错误
     */
    override fun getCollectListError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 分页加载更多成功:追加数据并结束加载状态
     */
    override fun getCollectMoreListSuccess(collect: Collect) {
        CURRENT_SIZE = collect.datas.size
        mDataList.addAll(collect.datas)
        mCollectAdapter.addData(collect.datas)
        mCollectAdapter.loadMoreModule.loadMoreComplete()
    }

    /**
     * 分页加载更多失败:提示错误
     */
    override fun getCollectMoreListError(errorMessage: String) {
        show(errorMessage)
    }

    /**
     * 未登录回调:弹窗提示并跳转或关闭
     */
    override fun login(msg: String) {
        show(msg)
        AlertDialog.Builder(this@CollectActivity).apply {
            setTitle("提示")
            setMessage(msg)
            setPositiveButton("确定") { _, _ ->
                startActivity(Intent(this@CollectActivity, LoginActivity::class.java))
            }
            setNegativeButton("取消") { _, _ -> finish() }
        }.create().show()
    }

    /**
     * 初始化下拉刷新控件样式和监听
     */
    private fun initSwipeRefreshLayout() {
        binding.swipeRefresh.apply {
            setColorSchemeResources(
                android.R.color.holo_blue_bright,
                android.R.color.holo_green_light,
                android.R.color.holo_orange_light
            )
            setOnRefreshListener(this@CollectActivity)
        }
    }
}

至此,我们这个项目就完成了,不是很完美,但是能运行,毕竟,App和程序员之间,有一个能跑就行,O(∩_∩)O哈哈~!!

仓库地址

仓库设置 · NPC1号/MyStudyKotlinMVPWanAndroidExample - Gitee.com

相关推荐
雨白1 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹3 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空5 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭5 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日6 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安6 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑6 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟10 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡12 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0012 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体