Flutter(八)之豆瓣電影列表

這個練習小項目主要是爲了鍛鍊佈局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>{
  finaldouble 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 {
  finaldouble rating;
  finaldouble maxRating;
  final Widget unselectedImage;
  final Widget selectedImage;
  finalint count;
  finaldouble size;
  final Color unselectedColor;
  final Color selectedColor;

  HYStarRating({
    @requiredthis.rating,
    this.maxRating = 10,
    this.size = 30,
    this.unselectedColor = const Color(0xffbbbbbb),
    this.selectedColor = const Color(0xffe0aa46),
    Widget unselectedImage,
    Widget selectedImage,
    this.count = 5,
  }): unselectedImage = unselectedImage ?? Icon(Icons.star, size: size, color: unselectedColor,),
        selectedImage = selectedImage ?? Icon(Icons.star, size: size, color: selectedColor);

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

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

  // 獲取評星
  List<Widget> getUnSelectImage() {
    returnList.generate(widget.count, (index) => widget.unselectedImage);
  }

  List<Widget> getSelectImage() {
    // 1.計算Star個數和剩餘比例等
    double oneValue = widget.maxRating / widget.count;
    int entireCount = (widget.rating / oneValue).floor();
    double leftValue = widget.rating - entireCount * oneValue;
    double leftRatio = leftValue / oneValue;

    // 2.獲取start
    List<Widget> selectedImages = [];
    for (int i = 0; i < entireCount; i++) {
      selectedImages.add(widget.selectedImage);
    }

    // 3.計算
    Widget leftStar = ClipRect(
      clipper: MyRectClipper(width: leftRatio * widget.size),
      child: widget.selectedImage,
    );
    selectedImages.add(leftStar);

    return selectedImages;
  }
}


class MyRectClipper extends CustomClipper<Rect>{
  finaldouble 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;
  }
}

二. DashedLine

2.1. 最終實現效果

目的:實現效果的同時,提供定製,並且可以實現水平和垂直兩種虛線效果:

  • axis:確定虛線的方向;
  • dashedWidth:根據虛線的方向確定自己虛線的寬度;
  • dashedHeight:根據虛線的方向確定自己虛線的高度;
  • count:內部會根據設置的個數和寬高確定密度(虛線的空白間隔);
  • color:虛線的顏色,不多做解釋;

暫時實現上面的定製,後續有新的需求繼續添加新的功能點~

2.2. 實現思路分析

實現比較簡單,主要是根據用戶傳入的方向確定添加對應的SizedBox即可。

這裏有一個注意點:虛線到底是設置多寬或者多高呢?

  • 我這裏是根據方向獲取父Widget的寬度和高度來決定的;
  • 通過LayoutBuilder可以獲取到父Widget的寬度和高度;
return LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // 根據寬度計算個數
    return Flex(
      direction: this.axis,
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: List.generate(this.count, (int index) {
        return SizedBox(
          width: dashedWidth,
          height: dashedHeight,
          child: DecoratedBox(
            decoration: BoxDecoration(color: color),
          ),
        );
      }),
    );
  },
);

2.3. 最終代碼實現

比較簡單的封裝,直接給出最終代碼實現:

class HYDashedLine extends StatelessWidget {
  final Axis axis;
  finaldouble dashedWidth;
  finaldouble dashedHeight;
  finalint count;
  final Color color;

  HYDashedLine({
    @requiredthis.axis,
    this.dashedWidth = 1,
    this.dashedHeight = 1,
    this.count,
    this.color = const Color(0xffff0000)
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        // 根據寬度計算個數
        return Flex(
          direction: this.axis,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: List.generate(this.count, (int index) {
            return SizedBox(
              width: dashedWidth,
              height: dashedHeight,
              child: DecoratedBox(
                decoration: BoxDecoration(color: color),
              ),
            );
          }),
        );
      },
    );
  }
}

三. 實現底部TabBar

3.1. 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,
    @requiredthis.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來管理多個頁面的切換:

body: IndexedStack(
  index: _currentIndex,
  children: <Widget>[
    Home(),
    Subject(),
    Group(),
    Mall(),
    Profile()
  ],
 )

3.2. TabBar代碼實現

注意事項:

  • 1、我們需要在其他地方創建對應要切換的頁面;
  • 2、需要引入對應的資源,並且在pubspec.yaml中引入;
import'package:flutter/material.dart';
import'views/home/home.dart';
import'views/subject/subject.dart';
import'views/group/group.dart';
import'views/mall/mall.dart';
import'views/profile/profile.dart';


class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "豆瓣",
      theme: ThemeData(
        primaryColor: Colors.green,
        highlightColor: Colors.transparent,
        splashColor: Colors.transparent
      ),
      home: MyStackPage(),
    );
  }
}

class MyStackPage extends StatefulWidget {
  @override
  _MyStackPageState createState() => _MyStackPageState();
}

class _MyStackPageState extends State<MyStackPage> {

  var _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        selectedFontSize: 14,
        unselectedFontSize: 14,
        type: BottomNavigationBarType.fixed,
        items: [
          createItem("home", "首頁"),
          createItem("subject", "書影音"),
          createItem("group", "小組"),
          createItem("mall", "市集"),
          createItem("profile", "我的"),
        ],
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
      ),
      body: IndexedStack(
        index: _currentIndex,
        children: <Widget>[
          Home(),
          Subject(),
          Group(),
          Mall(),
          Profile()
        ],
      ),
    );
  }
}

BottomNavigationBarItem createItem(String iconName, String title) {
  return BottomNavigationBarItem(
      icon: Image.asset("assets/images/tabbar/$iconName.png", width: 30,),
      activeIcon: Image.asset("assets/images/tabbar/${iconName}_active.png", width: 30,),
      title: Text(title)
  );
}

四. 數據請求和轉化

4.1. 網絡請求簡單封裝

目前我還沒有詳細講解網絡請求相關的知識,開發中我們更多選擇地方的dio。

後面我會詳細講解網絡請求的幾種方式,我這裏基於dio進行了一個簡單工具的封裝:

配置文件存放:http_config.dart

const baseURL = "https://httpbin.org";
const timeout = 5000;

網絡請求工具文件:http_request.dart

  • 目前只是封裝了一個方法,更多細節後續再補充
import'package:dio/dio.dart';
import'http_config.dart';

class HttpRequest {
  // 1.創建實例對象
  static BaseOptions baseOptions = BaseOptions(connectTimeout: timeout);
  static Dio dio = Dio(baseOptions);

  static Future<T> request<T>(String url, {String method = "get",Map<String, dynamic> params}) async {
    // 1.單獨相關的設置
    Options options = Options();
    options.method = method;

    // 2.發送網絡請求
    try {
      Response response = await dio.request<T>(url, queryParameters: params, options: options);
      return response.data;
    } on DioError catch (e) {
      throw e;
    }
  }
}

4.2. 首頁數據請求轉化

豆瓣數據的獲取

這裏我使用豆瓣的API接口來請求數據:

  • https://douban.uieee.com/v2/movie/top250?start=0&count=20

模型對象的封裝

在面向對象的開發中,數據請求下來並不會像前端那樣直接使用,而是封裝成模型對象:

  • 前端開發者很容易沒有面向對象的思維或者類型的思維。
  • 但是目前前端開發正在向TypeScript發展,也在幫助我們強化這種思維方式。
    爲了方便之後使用請求下來的數據,我將數據劃分成瞭如下的模型:

Person、Actor、Director模型:它們會被使用到MovieItem中

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);
}

MovieItem模型:

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>();
    this.casts = (json["casts"] asList<dynamic>).map((item) {
      return Actor.fromMap(item);
    }).toList();
    this.director = Director.fromMap(json["directors"][0]);
    this.originalTitle = json["original_title"];
  }
}
首頁

數據請求封裝以及模型轉化

這裏我封裝了一個專門的類,用於請求首頁的數據,這樣讓我們的請求代碼更加規範的管理:HomeRequest

  • 目前類中只有一個方法getMovieTopList;
  • 後續有其他首頁數據需要請求,就繼續在這裏封裝請求的方法;
import'package:douban_app/models/home_model.dart';
import'http_request.dart';

class HomeRequest {
  Future<List<MovieItem>> getMovieTopList(int start, int count) async {
    // 1.拼接URL
    final url = "https://douban.uieee.com/v2/movie/top250?start=$start&count=$count";

    // 2.發送請求
    final result = await HttpRequest.request(url);

    // 3.轉成模型對象
    final subjects = result["subjects"];
    List<MovieItem> movies = [];
    for (var sub in subjects) {
      movies.add(MovieItem.fromMap(sub));
    }

    return movies;
  }
}

在home.dart文件中請求數據

五. 界面效果實現

5.1. 首頁整體代碼

首頁整體佈局非常簡單,使用一個ListView即可

import'package:douban_app/models/home_model.dart';
import'package:douban_app/network/home_request.dart';
import'package:douban_app/views/home/childCpns/movie_list_item.dart';
import'package:flutter/material.dart';

const COUNT = 20;

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("首頁"),
      ),
      body: Center(
        child: HomeContent(),
      ),
    );
  }
}

class HomeContent extends StatefulWidget {
  @override
  _HomeContentState createState() => _HomeContentState();
}

class _HomeContentState extends State<HomeContent> {
  // 初始化首頁的網絡請求對象
  HomeRequest homeRequest = HomeRequest();

  int _start = 0;
  List<MovieItem> movies = [];

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

    // 請求電影列表數據
    getMovieTopList(_start, COUNT);
  }

  void getMovieTopList(start, count) {
    homeRequest.getMovieTopList(start, count).then((result) {
      setState(() {
        movies.addAll(result);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: movies.length,
      itemBuilder: (BuildContext context, int index) {
        return MovieListItem(movies[index]);
      }
    );
  }
}

5.2. 單獨Item局部

下面是針對界面結構的分析:

大家按照對應的結構,實現代碼即可:

import'package:douban_app/components/dash_line.dart';
import'package:flutter/material.dart';

import'package:douban_app/models/home_model.dart';
import'package:douban_app/components/star_rating.dart';

class MovieListItem extends StatelessWidget {
  final MovieItem movie;

  MovieListItem(this.movie);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(10),
      decoration: BoxDecoration(
          border: Border(bottom: BorderSide(width: 10, color: Color(0xffe2e2e2)))
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          // 1.電影排名
          getMovieRankWidget(),
          SizedBox(height: 12),
          // 2.具體內容
          getMovieContentWidget(),
          SizedBox(height: 12),
          // 3.電影簡介
          getMovieIntroduceWidget(),
          SizedBox(height: 12,)
        ],
      ),
    );
  }

  // 電影排名
  Widget getMovieRankWidget() {
    return Container(
      padding: EdgeInsets.fromLTRB(9, 4, 9, 4),
      decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(3),
          color: Color.fromARGB(255, 238, 205, 144)
      ),
      child: Text(
        "No.${movie.rank}",
        style: TextStyle(fontSize: 18, color: Color.fromARGB(255, 131, 95, 36)),
      )
    );
  }

  // 具體內容
  Widget getMovieContentWidget() {
    return Container(
      height: 150,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          getContentImage(),
          getContentDesc(),
          getDashLine(),
          getContentWish()
        ],
      ),
    );
  }

  Widget getContentImage() {
    return ClipRRect(
      borderRadius: BorderRadius.circular(5),
      child: Image.network(movie.imageURL)
    );
  }

  Widget getContentDesc() {
    return Expanded(
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 15),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            getTitleWidget(),
            SizedBox(height: 3,),
            getRatingWidget(),
            SizedBox(height: 3,),
            getInfoWidget()
          ],
        ),
      ),
    );
  }

  Widget getDashLine() {
    return Container(
      width: 1,
      height: 100,
      child: DashedLine(
        axis: Axis.vertical,
        dashedHeight: 6,
        dashedWidth: .5,
        count: 12,
      ),
    );
  }

  Widget getTitleWidget() {
    return Stack(
      children: <Widget>[
        Icon(Icons.play_circle_outline, color: Colors.redAccent,),
        Text.rich(
          TextSpan(
            children: [
              TextSpan(
                text: "     " + movie.title,
                style: TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold
                )
              ),
              TextSpan(
                text: "(${movie.playDate})",
                style: TextStyle(
                    fontSize: 18,
                    color: Colors.black54
                ),
              )
            ]
          ),
          maxLines: 2,
        ),
      ],
    );
  }

  Widget getRatingWidget() {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        StarRating(rating: movie.rating, size: 18,),
        SizedBox(width: 5),
        Text("${movie.rating}")
      ],
    );
  }

  Widget getInfoWidget() {
    // 1.獲取種類字符串
    final genres = movie.genres.join(" ");
    final director = movie.director.name;
    var castString = "";
    for (final cast in movie.casts) {
      castString += cast.name + " ";
    }

    // 2.創建Widget
    return Text(
      "$genres / $director / $castString",
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
      style: TextStyle(fontSize: 16),
    );
  }

  Widget getContentWish() {
    return Container(
      width: 60,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          SizedBox(height: 20,),
          Image.asset("assets/images/home/wish.png", width: 30,),
          SizedBox(height: 5,),
          Text(
            "想看",
            style: TextStyle(fontSize: 16, color: Color.fromARGB(255, 235, 170, 60)),
          )
        ],
      ),
    );
  }

  // 電影簡介(原生名稱)
  Widget getMovieIntroduceWidget() {
    return Container(
      width: double.infinity,
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Color(0xfff2f2f2),
        borderRadius: BorderRadius.circular(5)
      ),
      child: Text(movie.originalTitle, style: TextStyle(fontSize: 18, color: Colors.black54),),
    );
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章