请添加图片描述

书籍列表是整个App的核心页面之一,用户可以在这里查看自己所有的书籍,按状态筛选,还能快速添加新书。今天来实现这个页面,主要涉及TabBar切换和网格布局。

做书籍列表的时候,我考虑了几种展示方式:列表、网格、书架。最后选了网格布局,因为书籍封面是竖向的,网格布局能更好地展示封面,视觉效果也更像真实的书架。

页面整体结构

书籍列表用 StatefulWidget 实现,因为需要管理 TabController 的状态。

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../app/routes/app_routes.dart';

class BookListPage extends StatefulWidget {
  const BookListPage({super.key});
  
  State<BookListPage> createState() => _BookListPageState();
}

导入必要的依赖,StatefulWidget 是因为 TabController 需要 TickerProvider,必须用有状态组件。

状态类定义

class _BookListPageState extends State<BookListPage> with SingleTickerProviderStateMixin {
  late TabController _tabController;
  final List<String> _tabs = ['全部', '在读', '已读', '想读'];

  
  void initState() {
    super.initState();
    _tabController = TabController(length: _tabs.length, vsync: this);
  }

  
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

SingleTickerProviderStateMixin 提供动画所需的 Ticker。TabController 在 initState 中初始化,在 dispose 中销毁,这是标准的生命周期管理。

四个Tab分别是:全部、在读、已读、想读。这样用户可以快速筛选不同状态的书籍。

AppBar 和 TabBar

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFDF8F3),
      appBar: AppBar(
        title: const Text('我的书籍'),
        backgroundColor: const Color(0xFF5B4636),
        foregroundColor: Colors.white,
        automaticallyImplyLeading: false,
        actions: [
          IconButton(icon: const Icon(Icons.filter_list), onPressed: () {}),
          IconButton(icon: const Icon(Icons.search), onPressed: () => Get.toNamed(AppRoutes.search)),
        ],

automaticallyImplyLeading: false 是因为这个页面是底部导航的一个Tab,不需要返回按钮。右上角放了筛选和搜索两个按钮。

TabBar 样式

        bottom: TabBar(
          controller: _tabController,
          indicatorColor: Colors.white,
          labelColor: Colors.white,
          unselectedLabelColor: Colors.white70,
          tabs: _tabs.map((t) => Tab(text: t)).toList(),
        ),
      ),

TabBar 放在 AppBar 的 bottom 位置,指示器和选中文字都是白色,未选中的文字是半透明白色。这样和深棕色背景形成对比,看起来很清晰。

TabBarView 内容

      body: TabBarView(
        controller: _tabController,
        children: _tabs.map((t) => _buildBookGrid()).toList(),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: const Color(0xFF5B4636),
        onPressed: () => Get.toNamed(AppRoutes.addBook),
        child: const Icon(Icons.add, color: Colors.white),
      ),
    );
  }

每个Tab对应一个书籍网格,目前都用同一个方法生成,实际项目中应该根据Tab筛选不同状态的书籍。

右下角的 FloatingActionButton 用来添加新书,点击跳转到添加书籍页面。

书籍数据

  Widget _buildBookGrid() {
    final books = [
      {'title': '百年孤独', 'author': '加西亚·马尔克斯', 'status': '在读', 'rating': 4.5},
      {'title': '人类简史', 'author': '尤瓦尔·赫拉利', 'status': '在读', 'rating': 4.8},
      {'title': '三体', 'author': '刘慈欣', 'status': '已读', 'rating': 5.0},
      {'title': '活着', 'author': '余华', 'status': '已读', 'rating': 4.9},
      {'title': '小王子', 'author': '圣埃克苏佩里', 'status': '想读', 'rating': 4.7},
      {'title': '1984', 'author': '乔治·奥威尔', 'status': '想读', 'rating': 4.6},
    ];

书籍数据暂时写死,包含书名、作者、状态、评分四个字段。实际项目中这些数据应该从数据库查询。

GridView 配置

    return GridView.builder(
      padding: EdgeInsets.all(16.w),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        mainAxisSpacing: 16.h,
        crossAxisSpacing: 16.w,
        childAspectRatio: 0.65,
      ),
      itemCount: books.length,
      itemBuilder: (context, index) {
        final book = books[index];
        return GestureDetector(
          onTap: () => Get.toNamed(AppRoutes.bookDetail),

GridView.builderGridView.count 性能更好,因为它是懒加载的。crossAxisCount: 2 表示每行两本书,childAspectRatio: 0.65 控制卡片的宽高比,让它更接近书籍封面的比例。

书籍卡片容器

          child: Container(
            decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12.r)),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(
                  flex: 3,
                  child: Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFF5B4636).withOpacity(0.1),
                      borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
                    ),
                    child: Center(child: Icon(Icons.menu_book, size: 48.sp, color: const Color(0xFF5B4636))),
                  ),
                ),

卡片分为上下两部分,上面是封面区域(占3份),下面是信息区域(占2份)。封面区域用浅棕色背景加书籍图标,实际项目中应该显示真实的封面图片。

BorderRadius.vertical(top: ...) 只给顶部加圆角,底部是直角,这样和下面的信息区域衔接更自然。

书籍信息区域

                Expanded(
                  flex: 2,
                  child: Padding(
                    padding: EdgeInsets.all(10.w),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(book['title'] as String, style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14.sp), maxLines: 1, overflow: TextOverflow.ellipsis),
                        SizedBox(height: 2.h),
                        Text(book['author'] as String, style: TextStyle(color: Colors.grey[600], fontSize: 11.sp), maxLines: 1),
                        const Spacer(),

书名用加粗字体,作者用灰色小字。maxLines: 1 配合 overflow: TextOverflow.ellipsis 处理文字过长的情况。

Spacer() 把评分和状态标签推到底部,保持布局一致。

评分和状态标签

                        Row(
                          children: [
                            Icon(Icons.star, color: Colors.amber, size: 14.sp),
                            SizedBox(width: 2.w),
                            Text('${book['rating']}', style: TextStyle(fontSize: 12.sp, color: Colors.grey[700])),
                            const Spacer(),
                            Container(
                              padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
                              decoration: BoxDecoration(color: const Color(0xFF5B4636).withOpacity(0.1), borderRadius: BorderRadius.circular(4.r)),
                              child: Text(book['status'] as String, style: TextStyle(fontSize: 10.sp, color: const Color(0xFF5B4636))),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

左边是评分,用星星图标加数字。右边是状态标签,用浅棕色背景的小标签显示。这样用户一眼就能看到每本书的状态。

为什么用网格布局

选择网格布局而不是列表布局,主要有几个原因:

书籍封面通常是竖向的,网格布局能更好地展示封面比例。如果用列表布局,要么封面很小,要么每个item占用太多高度。

网格布局更像真实的书架,用户看起来更直观。两列的布局在手机上刚好,既不会太挤也不会太空。

网格布局的信息密度更高,一屏能看到更多书籍,方便用户快速浏览和查找。

Tab 切换逻辑

目前四个Tab都显示同样的内容,实际项目中应该根据Tab筛选:

// 示例:根据状态筛选书籍
// final filteredBooks = books.where((b) => 
//   _tabs[_tabController.index] == '全部' || 
//   b['status'] == _tabs[_tabController.index]
// ).toList();

这个逻辑后面接入数据库的时候再实现,现在先把UI做出来。

添加书籍入口

右下角的 FloatingActionButton 是添加书籍的主要入口。选择 FAB 而不是 AppBar 按钮,是因为:

FAB 更醒目,用户一眼就能看到。添加书籍是高频操作,应该放在容易触达的位置。

FAB 在右下角,用户单手操作时拇指刚好能够到,符合人体工程学。

Material Design 规范推荐用 FAB 来放置页面的主要操作。

性能优化

GridView.builder 是懒加载的,只会渲染当前可见的item,对于书籍很多的情况性能更好。

如果书籍数量特别多(比如上千本),还可以考虑分页加载,每次只加载一部分数据。

小结

书籍列表页面的核心是 TabBar + GridView 的组合。TabBar 提供状态筛选,GridView 提供网格展示。代码组织上,把网格构建抽成独立方法,主 build 方法保持简洁。

下一篇会讲书籍详情页面的实现,涉及到 SliverAppBar 和更复杂的布局,敬请期待。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐