封装几个豆瓣电影小Widget

学习完列表渲染后,我们打算做一个综合一点的练习小项目:豆瓣Top电影排行列表。

这个练习小项目主要是为了锻炼布局Widget,但是也涉及到一些其他知识点:评分展示、分割线、bottomNavigationBar等。

这些内容,我们放到后面进行补充,但是在进行豆瓣Top电影模仿时,有两个东西实现起来比较复杂:

  1. 评分展示: 我们需要根据不同的评分显示不同的星级展示,这里我封装了一个StarRating的小Widget来实现。
  2. 分割线: 最初我考虑使用边框虚线来完成分割线,后来发现Flutter并不支持虚线边框,因此封装了一个DashedLine的小Widget来实现。

当然,这个章节如果你觉得过于复杂,可以直接把我封装好的两个东西拿过去使用。

一. StarRating

1.1. 最终效果展示

目的:实现功能展示的同时,提供高度的定制效果。

  • rating:必传参数,告诉Widget当前的评分;
  • maxRating:可选参数,最高评分,根据它来计算一个比例,默认值为10;
  • size:星星的大小,决定每一个star的大小;
  • unselectedColor:未选中星星的颜色(该属性是使用默认的star才有效);
  • selectedColor:选中星星的颜色(该属性也是使用默认的star才有效);
  • unselectedImage:定制未选中的star;
  • selectedImage:定义选中时的star;
  • count:展示星星的个数;

暂时实现上面的定制,后续有新的需求继续添加新的功能点。

1.2. 实现思路分析

理清楚思路后,你会发现并不是非常复杂,主要就是两点的展示:

  • 未选中star的展示:根据个数和传入的unselectedImage创建对应个数的Widget即可;
  • 选中star的展示:
    计算出满star的个数,创建对应的Widget;
    计算剩余比例的评分,对最后一个Widget进行裁剪;

问题一:选择StatelessWidget还是StatefulWidget?

考虑到后面可能会做用户点击进行评分或者用户手指滑动评分的效果,所以这里选择StatefulWidget。

  • 目前还没有讲解事件监听相关,所以暂时不添加这个功能。

问题二:如何让选中的star未选中的star重叠显示?

  • 非常简单,使用Stack即可;
child: Stack(
  children: <Widget>[
    Row(children: getUnSelectImage(), mainAxisSize: MainAxisSize.min,),
    Row(children: getSelectImage(), mainAxisSize: MainAxisSize.min,),
  ],
),

问题三:如何实现对选中的最后一个star进行裁剪?

  • 可以使用ClipRect定制CustomClipper进行裁剪。

定义CustomClipper裁剪规则:

class MyRectClipper extends CustomClipper<Rect>{
  final double width;

  MyRectClipper({
    this.width
  });

  @override
  Rect getClip(Size size) {
    return Rect.fromLTRB(0, 0, width, size.height);
  }

  @override
  bool shouldReclip(MyRectClipper oldClipper) {
    return width != oldClipper.width;
  }
}

使用MyRectClipper进行裁剪:

Widget leftStar = ClipRect(
  clipper: MyRectClipper(width: leftRatio * widget.size),
  child: widget.selectedImage,
);

1.3. 最终代码实现

最终代码并不复杂,而且我也有给出主要注释:


import 'package:flutter/material.dart';

class HYStarRating extends StatefulWidget {
  final double rating; //当前分数
  final double maxRating; // 最大分数
  final int count; // 星星个数
  final double size; // 星星大小
  final Color unselectedColor; // 未选中的颜色
  final Color selectedColor; // 选中的颜色
  final Widget unselectedImage; //未选中的图片
  final Widget selectedImage; //选中的图片

  // 初始化方法
  HYStarRating({
    @required this.rating,
    this.maxRating = 10,
    this.count = 5,
    this.size = 30,
    this.unselectedColor = const Color(0xffbbbbbb), // 默认值必须是个常量,所以加个const
    this.selectedColor = const Color(0xffff0000),
    Widget unselectedImage,
    Widget selectedImage
    // 初始化列表
  }): unselectedImage = unselectedImage ?? Icon(Icons.star_border, color: unselectedColor, size: size),
        selectedImage = selectedImage ?? Icon(Icons.star, color: selectedColor, size: size);

  @override
  _HYStarRatingState createState() => _HYStarRatingState();
}

class _HYStarRatingState extends State<HYStarRating> {
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Row(mainAxisSize: MainAxisSize.min, children: buildUnselectedStar()),
        Row(mainAxisSize: MainAxisSize.min, children: buildSelectedStar())
      ],
    );
  }

  // 创建未选中的star
  List<Widget> buildUnselectedStar() {
    // 如果在state里面想拿到widget里面的数据,直接使用widget即可
    return List.generate(widget.count, (index) {
      return widget.unselectedImage;
    });
  }

  // 创建选中的star
  List<Widget> buildSelectedStar() {
    // 1.创建stars
    List<Widget> stars = [];
    final star = widget.selectedImage;

    // 2.构建满填充的star
    // 一个星代表几分
    double oneValue = widget.maxRating / widget.count;
    // floor向下取整,获取有几个整的星
    // floor是地板的意思,向下取整,ceil是天花板的意思,向上取整
    int entireCount = (widget.rating / oneValue).floor();
    for (var i = 0; i < entireCount; i++) {
      stars.add(star);
    }

    // 3.构建部分填充star
    // (widget.rating / oneValue) 3.5 - 3 = 0.5 * widget.size
    // 计算需要裁剪的宽度
    double leftWidth = ((widget.rating / oneValue) - entireCount) * widget.size;
    final halfStar = ClipRect(
        clipper: HYStarClipper(leftWidth),
        child: star
    );
    stars.add(halfStar);

    // 边界处理
    if (stars.length > widget.count) {
      return stars.sublist(0, widget.count);
    }

    return stars;
  }
}

// 系统提供的抽象类的子类我们用不了,自己实现一个
class HYStarClipper extends CustomClipper<Rect> {
  double width;

  HYStarClipper(this.width);

  @override
  Rect getClip(Size size) {
    // 返回裁剪的范围
    return Rect.fromLTRB(0, 0, width, size.height);
  }

  @override
  bool shouldReclip(HYStarClipper oldClipper) {
    // 宽度变化之后才裁剪
    return oldClipper.width != this.width;
  }
}

二. DashedLine

2.1. 最终实现效果

目的:实现效果的同时,提供定制,并且可以实现水平和垂直两种虚线效果。

  • axis:虚线的方向;
  • dashedWidth:虚线的宽度;
  • dashedHeight:虚线的高度;
  • count:虚线的个数;
  • color:虚线的颜色;

暂时实现上面的定制,后续有新的需求继续添加新的功能点。

2.2. 实现思路分析

  1. 首先需要使用Container包裹一下虚线,Container设置宽度或者高度。
  2. 每个SizedBox就是一个虚线,给虚线设置宽高颜色以后,直接spaceBetween方式排布到Container里面即可。

2.3. 最终代码实现

import 'package:flutter/material.dart';

class HYDashedLine extends StatelessWidget {
  final Axis axis; //虚线的方向
  final double dashedWidth; // 虚线宽度
  final double dashedHeight;// 虚线高度
  final int count; // 虚线个数
  final Color color;

  HYDashedLine({
    // 设置默认值
    this.axis = Axis.horizontal,
    this.dashedWidth = 1,
    this.dashedHeight = 1,
    this.count = 10,
    this.color = Colors.red
  });

  @override
  Widget build(BuildContext context) {
    return Flex(
      direction: axis,
      // 设置虚线的排布方式
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: List.generate(count, (_) {
        // 每一个SizedBox就是一个虚线
        return SizedBox(
          width: dashedWidth,
          height: dashedHeight,
          // 给SizedBox设置背景色
          child: DecoratedBox(
            decoration: BoxDecoration(color: color),
          ),
        );
      }),
    );
  }
}

使用方式如下:

实现效果如下:

三. 实现底部TabBar

在即将开始的小练习中,我们有实现一个底部的TabBar,如何实现呢?

在Flutter中,我们会使用Scaffold来搭建页面的基本结构,实际上它里面有一个属性就可以实现底部TabBar功能:bottomNavigationBar。

bottomNavigationBar对应的类型是BottomNavigationBar,我们来看一下它有什么属性,属性非常多,但是都是设置底部TabBar相关的,我们介绍几个:

  • currentIndex:当前选中哪一个item;
  • selectedFontSize:选中时的文本大小;
  • unselectedFontSize:未选中时的文本大小;
  • type:当item的数量超过2个时,需要设置为fixed;
  • items:放入多个BottomNavigationBarItem类型;
  • onTap:监听哪一个item被选中;
class BottomNavigationBar extends StatefulWidget {
  BottomNavigationBar({
    Key key,
    @required this.items,
    this.onTap,
    this.currentIndex = 0,
    this.elevation = 8.0,
    BottomNavigationBarType type,
    Color fixedColor,
    this.backgroundColor,
    this.iconSize = 24.0,
    Color selectedItemColor,
    this.unselectedItemColor,
    this.selectedIconTheme = const IconThemeData(),
    this.unselectedIconTheme = const IconThemeData(),
    this.selectedFontSize = 14.0,
    this.unselectedFontSize = 12.0,
    this.selectedLabelStyle,
    this.unselectedLabelStyle,
    this.showSelectedLabels = true,
    bool showUnselectedLabels,
  }) 
}

当实现了底部TabBar展示后,我们需要监听它的点击来切换显示不同的页面,这个时候我们可以使用IndexedStack来管理多个页面的切换:

main/main.dart代码如下:

import 'package:flutter/material.dart';
import 'initialize_items.dart';

class HYMainPage extends StatefulWidget {
  @override
  _HYMainPageState createState() => _HYMainPageState();
}

class _HYMainPageState extends State<HYMainPage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 以前我们演示代码的时候,MaterialApp里面有个home属性,我们把home属性设置为HYHomePage
      // 然后在HYHomePage里面添加Scaffold,appBar中的appBar就是导航条,body就是我们演示的页面

      // 现在body我们要设置为IndexedStack,这个组件的index属性是多少,就会将对应index的page放到最上面
      // 这就实现了点击底部tabbar切换page的效果了
      body: IndexedStack(
        // 当前显示哪个界面
        index: _currentIndex,
        // 页面数组
        children: pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        // 设置文字大小
        selectedFontSize: 14,
        unselectedFontSize: 14,
        selectedItemColor: Colors.red,
        // 默认选中哪个item
        currentIndex: _currentIndex,
        // 当下面的item超过2个的时候,要设置这个属性为fixed,否则文字将会不显示
        type: BottomNavigationBarType.fixed,
        // items数组
        items: items,
        // 点击事件
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
      ),
    );
  }
}

整个项目的main.dart代码如下:

import 'package:flutter/material.dart';
import 'pages/main/main.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.green,
        splashColor: Colors.transparent,
        // 默认点击的时候会有水波纹的效果,设置为透明,去掉这个效果
        highlightColor: Colors.transparent
      ),
      home: HYMainPage(),
    );
  }
}

我们把创建item和page的代码抽出来了。

创建item的代码,bottom_bar_item.dart文件如下:

import 'package:flutter/material.dart';

//将这些代码抽到一个方法中是可以的,当然也可以抽到类中,然后将参数传递给父类即可
//使用了初始化列表
class HYBottomBarItem extends BottomNavigationBarItem {
  HYBottomBarItem(String iconName, String title)
      : super(
    title: Text(title),
    icon: Image.asset("assets/images/tabbar/$iconName.png", width: 32, gaplessPlayback: true,),
    activeIcon: Image.asset("assets/images/tabbar/${iconName}_active.png", width: 32, gaplessPlayback: true,),
  );
}

创建items和pages的代码,initialize_items.dart文件如下:

import 'package:flutter/material.dart';
import '../home/home.dart';
import '../subject/subject.dart';
import 'bottom_bar_item.dart';

// 获取到item列表
List<HYBottomBarItem> items = [
  HYBottomBarItem("home", "首页"),
  HYBottomBarItem("subject", "书影音"),
  HYBottomBarItem("group", "小组"),
  HYBottomBarItem("mall", "市集"),
  HYBottomBarItem("profile", "我的"),
];

List<Widget> pages = [
  HYHomePage(),
  HYSubjectPage(),
  HYSubjectPage(),
  HYSubjectPage(),
  HYSubjectPage(),
];

首页home.dart代码如下:

import 'package:flutter/material.dart';
import 'home_content.dart';

class HYHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("首页"),
      ),
      body: Text("我是首页"),
    );
  }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容