练习高仿豆瓣电影列表

案例地址:Flutter_豆瓣案例

一. 数据请求和转化

1.1. 首页数据请求转化

豆瓣数据的获取

这里我使用豆瓣的API接口来请求数据:

模型对象的封装

在面向对象的开发中,数据请求下来并不会像前端那样直接使用,而是封装成模型对象:

  • 前端开发者很容易没有面向对象的思维或者类型的思维。
  • 但是目前前端开发正在向TypeScript发展,也在帮助我们强化这种思维方式。

为了方便之后使用请求下来的数据,我将数据划分成了如下的模型:

Person、Actor、Director模型:它们会被使用到MovieItem中,home_model.dart代码如下:

class Person {
  String name;
  String avatarURL;

  Person.fromMap(Map<String, dynamic> json) {
    this.name = json["name"];
    this.avatarURL = json["avatars"]["medium"];
  }
}

class Actor extends Person {
  Actor.fromMap(Map<String, dynamic> json): super.fromMap(json);
}

class Director extends Person {
  Director.fromMap(Map<String, dynamic> json): super.fromMap(json);
}

int counter = 1;

class MovieItem {
  int rank;
  String imageURL;
  String title;
  String playDate;
  double rating;
  List<String> genres;
  List<Actor> casts;
  Director director;
  String originalTitle;

  MovieItem.fromMap(Map<String, dynamic> json) {
    // 电影排名
    this.rank = counter++;
    this.imageURL = json["images"]["medium"];
    this.title = json["title"];
    this.playDate = json["year"];
    this.rating = json["rating"]["average"];
    this.genres = json["genres"].cast<String>();
    // casts里面是演员,转成List后,就可以使用map方法将演员map转成演员模型
    this.casts = (json["casts"] as List<dynamic>).map((item) {
      return Actor.fromMap(item);
    }).toList();
    this.director = Director.fromMap(json["directors"][0]);
    this.originalTitle = json["original_title"];
  }

  @override
  // 重写这个方法以后,打印的时候会把模型的所有属性都信息都打印出来
  String toString() {
    return 'MovieItem{rank: $rank, imageURL: $imageURL, title: $title, playDate: $playDate, rating: $rating, genres: $genres, casts: $casts, director: $director, originalTitle: $originalTitle}';
  }
}

补充:鼠标选中MovieItem,按command+n,可以快速生成构造器以及toString方法。

首页数据请求封装以及模型转化

这里我封装了一个专门的类,用于请求首页的数据,这样让我们的请求代码更加规范的管理:HomeRequest。

  • 目前类中只有一个方法requestMovieList;
  • 后续有其他首页数据需要请求,就继续在这里封装请求的方法;
import 'package:learn_flutter/douban/model/home_model.dart';
import 'config.dart';
import 'http_request.dart';

class HomeRequest {
  // 类方法,返回一个Future
  static Future<List<MovieItem>> requestMovieList(int start) async {
    // 1.构建URL
    final movieURL = "/movie/top250?start=$start&count=${HomeConfig.movieCount}";

    // 2.发送网络请求获取结果
    final result = await HttpRequest.request(movieURL);
    final subjects = result["subjects"];

    // 3.将Map转成Model
    List<MovieItem> movies = [];
    for (var sub in subjects) {
      movies.add(MovieItem.fromMap(sub));
    }

    return movies;
  }
}

在home_content.dart文件中请求数据

二. 界面效果实现

2.1. 首页整体代码

首页整体布局非常简单,使用一个ListView即可。

import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/_06_service//home_request.dart';
import 'home_movie_item.dart';

class HYHomeContent extends StatefulWidget {
  @override
  _HYHomeContentState createState() => _HYHomeContentState();
}

class _HYHomeContentState extend s State<HYHomeContent> {
  // 保存数据
  final List<MovieItem> movies = [];

  @override
  void initState() {
    super.initState();
    // 发送网络请求
    HomeRequest.requestMovieList(0).then((res) {
      setState(() {
        movies.addAll(res);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: movies.length,
      itemBuilder: (ctx, index) {
        // 渲染数据
        return HYHomeMovieItem(movies[index]);
      }
    );
  }
}

2.2. 单独Item局部

下面是针对界面结构的分析:

大家按照对应的结构,实现代码,home_movie_item.dart文件如下:

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/douban/utils/log.dart';
import 'package:learn_flutter/douban/widgets/dashed_line.dart';
import 'package:learn_flutter/douban/widgets/star_rating.dart';

class HYHomeMovieItem extends StatelessWidget {
  final MovieItem movie;

  HYHomeMovieItem(this.movie);

  @override
  Widget build(BuildContext context) {
    return Container(
      // 包裹一层Container是为了好设置内边距
      padding: EdgeInsets.all(8),
      // 底部加边框,设置分隔条
      decoration: BoxDecoration(
          border:
              Border(bottom: BorderSide(width: 8, color: Color(0xffcccccc)))),
      child: Column(
        // 交叉轴左对齐
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          // 1. 头部的布局
          buildHeader(),
          SizedBox(
            height: 8,
          ),
          // 2. 内容布局
          buildContent(),
          SizedBox(
            height: 8,
          ),
          // 3. 尾部布局
          buildFooter(),
        ],
      ),
    );
  }

  // 1.头部的布局
  Widget buildHeader() {
    return Container(
      padding: EdgeInsets.fromLTRB(10, 5, 10, 5),
      decoration: BoxDecoration(
          color: Color.fromARGB(255, 238, 205, 144),
          borderRadius: BorderRadius.circular(3)),
      child: Text(
        "No.${movie.rank}",
        style: TextStyle(fontSize: 18, color: Color.fromARGB(255, 131, 95, 36)),
      ),
    );
  }

  // 2.内容的布局
  Widget buildContent() {
    return Row(
      // 交叉轴从头开始
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        buildContentImage(),
        SizedBox(
          width: 8,
        ),
        Expanded(
          // 添加IntrinsicHeight组件就能保证内容+虚线+想看高度都是一样的,这样我们就不需要设置虚线和想看的高度了
          child: IntrinsicHeight(
            child: Row(
              children: <Widget>[
                buildContentInfo(),
                SizedBox(
                  width: 8,
                ),
                // 虚线
                buildContentLine(),
                SizedBox(
                  width: 8,
                ),
                // 想看
                buildContentWish()
              ],
            ),
          ),
        )
      ],
    );
  }

  // 2.1.内容的图片
  Widget buildContentImage() {
    return ClipRRect( // 设置圆角,这种方式简单方便
        borderRadius: BorderRadius.circular(8),
        child: Image.network(
          movie.imageURL,
          // 设置高度之后宽度会自适应比例,也就是宽高固定了
          height: 150,
        ));
  }

  // 2.2.内容的信息
  Widget buildContentInfo() {
    // 因为左边的图片和让Column都是在一个row里面,如果文字过多,文字会超出屏幕外面
    // 所以我们让Column变成可伸缩的,从而不超出屏幕
    return Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          buildContentInfoTitle(),
          SizedBox(
            height: 8,
          ),
          buildContentInfoRate(),
          SizedBox(
            height: 8,
          ),
          buildContentInfoDesc()
        ],
      ),
    );
  }

//  2.2.1 内容的信息的标题
  Widget buildContentInfoTitle() {
    List<InlineSpan> spans = [];

    // 图标+电影名称+年份,使用row也是可以实现的,但是以后文字多的时候row无法换行,所以使用如下方式
    return Text.rich(
      TextSpan(children: [
        // 以前我们用的是WidgetSpan+textSpan+textSpan,但是这样会导致前两个不在一条水平线上
        // 现在我们三个都使用WidgetSpan,然后前两个设置PlaceholderAlignment.middle,最后一个设置PlaceholderAlignment.bottom
        // 就可以让前两个中心点对齐,最后一个底部对齐
        // 图标
        WidgetSpan(
          child: Icon(
            Icons.play_circle_outline,
            color: Colors.pink,
            size: 40,
          ),
          baseline: TextBaseline.ideographic,
          alignment: PlaceholderAlignment.middle
        ),
        // 电影名称
        // WidgetSpan要么都是一行显示,要么就三行显示,如果电影的名字过长,就无法做到两行显示的效果
        // 我们的解决办法是让每个文字都是一个WidgetSpan,runes就是每个文字组成的数组,使用map映射成WidgetSpan的 Iterable,再转成数组,再展开
        ...movie.title.runes.map((rune) {
          return WidgetSpan(child: Text(new String.fromCharCode(rune), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),), alignment: PlaceholderAlignment.middle);
        }).toList(),
        // 年份
        WidgetSpan(child: Text("(${movie.playDate})"), style: TextStyle(fontSize: 18, color: Colors.grey), alignment: PlaceholderAlignment.bottom)
      ])
    );
  }

  // 2.2.2 星级评分组件
  Widget buildContentInfoRate() {
    // 当在iPhone5小尺寸手机上的时候,左边图片的宽度是固定的,右边的虚线和想看的宽度也是固定的
    // 这时候剩下的宽度也许都不够星星的宽度了,这时候就会报错
    // 我们添加FittedBox,这时候如果剩下的宽度不够了,就可以稍微缩小一点,这样就不会报错了
    return FittedBox(
      child: Row(
        children: <Widget>[
          // 以前封装的星级插件☆
          HYStarRating(
            rating: movie.rating,
            size: 20,
          ),
          SizedBox(
            width: 6,
          ),
          // 星级文字  
          Text(
            "${movie.rating}",
            style: TextStyle(fontSize: 16),
          )
        ],
      ),
    );
  }

  // 2.2.3 电影描述
  Widget buildContentInfoDesc() {
    // 1.字符串拼接
    // 数组元素以空格拼接
    final genresString = movie.genres.join(" ");
    // 导演
    final directorString = movie.director.name;
    List<Actor> casts = movie.casts;
    // 演员数组取出名字
    final actorString = movie.casts.map((item) => item.name).join(" ");

    return Text(
      "$genresString / $directorString / $actorString",
      maxLines: 2, //最多两行
      overflow: TextOverflow.ellipsis, //超出以后显示...
      style: TextStyle(fontSize: 16),
    );
  }

  // 2.3.内容的虚线
  Widget buildContentLine() {
    return Container(
//      height: 100,
      child: HYDashedLine(
        axis: Axis.vertical,
        dashedWidth: .4,
        dashedHeight: 6,
        count: 10,
        color: Colors.pink,
      ),
    );
  }

  // 2.4.内容的想看
  Widget buildContentWish() {
    return Container(
//      height: 100,
      child: Column(
        // 包裹一个Container,再设置为center,让❤和想看垂直居中
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Image.asset("assets/images/home/wish.png"),
          Text(
            "想看",
            style: TextStyle(
              fontSize: 18,
              color: Color.fromARGB(255, 235, 170, 60)
            ),
          )
        ],
      ),
    );
  }

  // 3.尾部的布局
  Widget buildFooter() {
    return Container(
      // 默认的宽度是内容包裹的宽度,设置为最大
      width: double.infinity,
      // 内边距
      padding: EdgeInsets.all(8),
      // 圆角
      decoration: BoxDecoration(
        color: Color(0xfff2f2f2),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Text(
        movie.originalTitle,
        style: TextStyle(fontSize: 20, color: Color(0xff666666)),
      ),
    );
  }
}

补充:Flutter默认的print打印只会打印信息,并没有所在行的信息,所以我们自定义一个打印:

void hyLog(Object message, StackTrace current) {
  HYCustomTrace programInfo = HYCustomTrace(current);
  print("所在文件: ${programInfo.fileName}, 所在行: ${programInfo.lineNumber}, 打印信息: $message");
}

class HYCustomTrace {
  final StackTrace _trace;

  String fileName;
  int lineNumber;
  int columnNumber;

  HYCustomTrace(this._trace) {
    _parseTrace();
  }

  void _parseTrace() {
    var traceString = this._trace.toString().split("\n")[0];
    var indexOfFileName = traceString.indexOf(RegExp(r'[A-Za-z_]+.dart'));
    var fileInfo = traceString.substring(indexOfFileName);
    var listOfInfos = fileInfo.split(":");
    this.fileName = listOfInfos[0];
    this.lineNumber = int.parse(listOfInfos[1]);
    var columnStr = listOfInfos[2];
    columnStr = columnStr.replaceFirst(")", "");
    this.columnNumber = int.parse(columnStr);
  }
}

使用方式如下:

hyLog("aaaaaa", StackTrace.current);
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容