flutter 项目实战

本文最后更新于:2022年4月22日 上午

豆瓣小项目实战

项目入口

通过 theme 将全局主题色修改为绿色

import 'package:flutter/material.dart';
import 'package:learn_fluuter_00/douban/main/main.dart';

main() => runApp(
      MaterialApp(
        theme: ThemeData(
          primaryColor: Colors.green,
          primarySwatch: Colors.green,
          splashColor: Colors.transparent,
        ),
        home: MainPage(),
      ),
    );

MainPage

indexdStack

可以通过索引来控制子元素的展示,类似于前端中修改定位元素的 z-index来让子元素展示

import 'package:flutter/material.dart';
import 'package:learn_fluuter_00/douban/main/config.dart';

class MainPage extends StatefulWidget {
  const MainPage({Key? key}) : super(key: key);

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(index: _currentIndex, children: pages),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        items: tabbar,
        onTap: (index) => setState(() {
          _currentIndex = index;
        }),
        unselectedFontSize: 14, // 将选中的字体和未选中的字体设为一致大小
        type: BottomNavigationBarType.fixed,
      ),
    );
  }
}

bottomNavigationBar

底部的 tabbar,小程序底部的tabbar,也是通过 index来控制当前展示的 item,这里需要注意的是如果 item 有 3 个以上后它底部的展示会是安卓风格的展示,底部的文字展示不全

(widget.items.length <= 3 ? BottomNavigationBarType.fixed : BottomNavigationBarType.shifting)

需要配置 typeBottomNavigationBarType.fixed来展示成 ios风格的样式

config

import 'package:flutter/material.dart';
import 'package:learn_fluuter_00/douban/profile/profile.dart';
import '../home/home.dart';

List<Widget> pages = [HomePage(), ProfilePage(), ProfilePage(), ProfilePage(), ProfilePage()];

List<BottomNavigationBarItem> tabbar = [
  BottomNavigationBarItem(
    label: '首页',
    icon: Image.asset('lib/assets/images/tabbar/home.png', width: 30),
    activeIcon: Image.asset('lib/assets/images/tabbar/home_active.png', width: 30),
  ),
  BottomNavigationBarItem(
    label: '书影音',
    icon: Image.asset('lib/assets/images/tabbar/subject.png', width: 30),
    activeIcon: Image.asset('lib/assets/images/tabbar/subject_active.png', width: 30),
  ),
  BottomNavigationBarItem(
    label: '小组',
    icon: Image.asset('lib/assets/images/tabbar/group.png', width: 30),
    activeIcon: Image.asset('lib/assets/images/tabbar/group_active.png', width: 30),
  ),
  BottomNavigationBarItem(
    label: '市集',
    icon: Image.asset('lib/assets/images/tabbar/mall.png', width: 30),
    activeIcon: Image.asset('lib/assets/images/tabbar/mall_active.png', width: 30),
  ),
  BottomNavigationBarItem(
    label: '我的',
    icon: Image.asset('lib/assets/images/tabbar/profile.png', width: 30),
    activeIcon: Image.asset('lib/assets/images/tabbar/profile_active.png', width: 30),
  ),
];

网络请求

根目录建立 services文件夹,同事新建 config.dartrequest.dart 文件

config.dart

存放 网络请求配置

class Config {
  static const baseUrl = 'https://m.douban.com/rexxar/api/v2/';

  static const timeout = 10000;
  static const method = 'GET';

  static const Map<String, dynamic> headers = {
    'Referer': 'https://m.douban.com/subject_collection/ECWY6B2GQ?dt_dapp=1',
  };
}

request.dart

import 'package:dio/dio.dart';
import './config.dart';

class Request {
  static final dio = Dio(
    BaseOptions(
      baseUrl: Config.baseUrl,
      connectTimeout: Config.timeout,
      method: Config.method,
      headers: Config.headers,
    ),
  );

  static Future request(String url, {Map<String, dynamic>? queryParameters, String? method}) async {
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handle) {
        print('请求拦截: ${options.uri} ${options.headers}');
        return handle.next(options);
        // return options;
      },
      onResponse: (options, handle) {
        print('响应拦截');
        return handle.next(options);
      },
      onError: (options, handle) {
        print('错误拦截:$options');
        return handle.next(options);
      },
    ));

    Options options = Options(method: method);
    Response<dynamic> res;

    try {
      res = await dio.request(url, queryParameters: queryParameters, options: options);
    } catch (err) {
      print('请求发生错误$err');
      return Future.error(err);
    }
    return res.data;
  }
}

HomePage

这里用到了之前封装的 评分组件 和 虚线分割线组件

import 'package:flutter/material.dart';
import 'package:learn_fluuter_00/douban/model/home_model.dart';
import 'package:learn_fluuter_00/douban/services/home_request.dart';
import 'package:learn_fluuter_00/widgets/star/dash_line.dart';
import 'package:learn_fluuter_00/widgets/star/star.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<HotMoiveItem> moiveList = [];

  @override
  void initState() {
    super.initState();

    HomeRequest.getHotMoive().then((res) {
      setState(() => moiveList.addAll(res));
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('首页')),
      body: ListView.builder(
        itemCount: moiveList.length,
        itemBuilder: (ctx, index) {
          final moive = moiveList[index];
          return Container(
            decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey[200]!, width: 15))),
            child: Padding(
              padding: EdgeInsets.all(10),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  buildRanking(index + 1),
                  SizedBox(height: 8),
                  buildContent(moive),
                  buildFooter(moive),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  // 构建底部
  Container buildFooter(HotMoiveItem moive) {
    return Container(
      width: double.infinity,
      padding: EdgeInsets.all(6),
      margin: EdgeInsets.symmetric(vertical: 15),
      decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(2)),
      child: Text('${moive.rating.count}评价', style: TextStyle(color: Colors.grey[700])),
    );
  }

  // 构建内容
  Widget buildContent(HotMoiveItem moive) {
    return IntrinsicHeight(
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 封面图
          ClipRRect(borderRadius: BorderRadius.circular(6), child: Image.network(moive.coverUrl, width: 85)),
          SizedBox(width: 10),
          buildContentInfo(moive), // 中间的内容

          // 虚线分割线
          Container(
              margin: EdgeInsets.symmetric(horizontal: 10),
              child: DashLine(axis: Axis.vertical, count: 20, height: 2, width: 1)),

          // 想看
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Image.asset('lib/assets/images/douban/icons/love.png', width: 20),
              SizedBox(height: 5),
              Text('想看', style: TextStyle(color: Colors.orange, fontSize: 12)),
            ],
          ),
        ],
      ),
    );
  }

  // 构建内容 info
  Expanded buildContentInfo(HotMoiveItem moive) {
    return Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 标题
          Text.rich(TextSpan(children: [
            WidgetSpan(child: Icon(Icons.play_circle_outline_rounded, color: Colors.red[500])),
            WidgetSpan(child: SizedBox(width: 3)),
            TextSpan(text: moive.title, style: TextStyle(fontSize: 18))
          ])),
          SizedBox(height: 3),

          // 评分星星
          Row(
            children: [
              Star(
                  score: moive.rating.value,
                  totalScore: moive.rating.max,
                  size: 17,
                  selectedColor: Colors.orange[400]!),
              SizedBox(width: 5),
              Text('${moive.rating.value}', style: TextStyle(color: Colors.orange[500], fontSize: 16))
            ],
          ),
          SizedBox(height: 2),

          // card标题
          Text(moive.cardSubtitle, style: TextStyle(color: Colors.grey[600]))
        ],
      ),
    );
  }

  // 构建头部排名
  Widget buildRanking(int ranking) {
    Color getColor() {
      switch (ranking) {
        case 1:
          return Colors.red[400]!;
        case 2:
          return Colors.orange[700]!;
        case 3:
          return Colors.orange[300]!;
        default:
          return Colors.grey;
      }
    }

    return Container(
      padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: getColor()),
      child: Text('No.$ranking', style: TextStyle(color: Colors.white)),
    );
  }
}

项目注意

CLipRRect

widget 矩形裁剪,如上,给封面图进行了圆角裁剪

IntrinsicHeight

该组件会使其内部的子组件都占满高度,如上代码中,给 Row包裹 IntrinsicHeight,则 row的所有子组件的高度会将Row占满

换行问题

可以使用富文本,或者将文字分割成数组,然后再一个个构建(文字太多可能会影响性能),

...title.runes.map((rune) {
  return WidgetSpan(child: Text(new String.formCharCode(rune)))
}).toList(),

富文本居中对齐

alignment: Alignment.middle

如何抓取接口

可在 豆瓣app 仅需需要抓取的页面,点击右上角分享当前页的链接,然后在 chrome 中查看请求接口

底部 bottomBar 首次点击图片闪烁问题

给每个 itemicon 都添加gaplessPlayback: true;

json 转 model

在线网址 https://app.quicktype.io/

弊端:在线网站会错误的转换成 double -> int 如 4.0 -> 4 JSON.parse转换的问题,包括 apifox也存在

vscode 插件 Paste JSON as Code

弊端:插件无法生成 formMap

当前实践是将 错误的类型手动修复

查看打印所在的行数

手动封装,或者查看是否有第三方库


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处。