从设计到Flutter #1 - 电影明细页面

模型

我创建了一个Movie类用于保存关于电影的信息。同样的,我也创建了一个Actor类用于保存演员的名字和头像url地址。这些仅仅是传入到我们的UI组件中,以便它们知晓要显示什么。

出于样例的目的,我创建一个文件,根据最初的设计,它保存了电影所需的所有模型。

models.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Movie {
Movie({
this.bannerUrl,
this.posterUrl,
this.title,
this.rating,
this.starRating,
this.categories,
this.storyline,
this.photoUrls,
this.actors,
});

final String bannerUrl;
final String posterUrl;
final String title;
final double rating;
final int starRating;
final List<String> categories;
final String storyline;
final List<String> photoUrls;
final List<Actor> actors;
}

class Actor {
Actor({
this.name,
this.avatarUrl,
});
final String name;
final String avatarUrl;
}

数据源

我们不打算将我们的应用程序与在线电影的API牵连在一起,使用其API本身就是另一个话题,这个超出了本文的范围。相反,我仅仅是创建了一个电影的实例,并填充了与模型相同的信息。

movie_api.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import 'package:movie_details_ui/models.dart';

final Movie testMovie = Movie(
bannerUrl: 'images/banner.png',
posterUrl: 'images/poster.png',
title: 'The Secret Life of Pets',
rating: 8.0,
starRating: 4,
categories: ['Animation', 'Comedy'],
storyline: 'For their fifth fully-animated feature-film '
'collaboration, Illumination Entertainment and Universal '
'Pictures present The Secret Life of Pets, a comedy about '
'the lives our...',
photoUrls: [
'images/1.png',
'images/2.png',
'images/3.png',
'images/4.png',
],
actors: [
Actor(
name: 'Louis C.K.',
avatarUrl: 'images/louis.png',
),
Actor(
name: 'Eric Stonestreet',
avatarUrl: 'images/eric.png',
),
Actor(
name: 'Kevin Hart',
avatarUrl: 'images/kevin.png',
),
Actor(
name: 'Jenny Slate',
avatarUrl: 'images/jenny.png',
),
Actor(
name: 'Ellie Kemper',
avatarUrl: 'images/ellie.png',
),
],
);

这个使我们能够在应用程序的任何地方来轻松使用地使用testMovie作为数据源,同时还能提高我们的速度,无需担心网络问题。

我们的 main.dart 文件

这个是我们应用程序的主入口,这里没有特别的地方,仅仅是将MovieDetailsPage作为该应用所拥有的唯一页面。

main.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'package:flutter/material.dart';
import 'package:movie_details_ui/movie_api.dart';
import 'package:movie_details_ui/movie_details_page.dart';

void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
accentColor: const Color(0xFFFF5959),
),
home: MovieDetailsPage(testMoive),
)
}
}

在现实生活中,我们可能希望是将电影列表作为第一页显示,在用户点击一个电影后显示明细页面。在本教程,我们只关注电影明细页面。

MovieDetailsPage类

这个是我们电影明细页的入口,MovieDetailsPage接收一个Movie对象作为构造参数,并向下传给子组件。通过这种方式,随后我们可以轻松地将页面链接到一个后端。

movie_details_page.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 'package:flutter/material.dart';
import 'package:movie_details_ui/actor_scroller.dart';
import 'package:movie_details_ui/models.dart';
import 'package:movie_details_ui/movie_detail_header.dart';
import 'package:movie_details_ui/photo_scroller.dart';
import 'package:movie_details_ui/story_line.dart';

class MovieDetailsPage extends StatelessWidget {
MovieDetailsPage(this.movie);
final Movie movie;

@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
MovieDetailHeader(movie),
Padding(
padding: const EdgeInsets.all(20.0),
child: Storyline(movie.storyline),
),
PhotoScroller(movie.photoUrls),
SizedBox(height: 20.0),
ActorScroller(movie.actors),
SizedBox(height: 50.0),
],
),
),
);
}
}

尽管这个页面包含在一个脚手架中,但我们这里不使用AppBar,因为我们想要展示我们的电影背景。主体是一个Column控件用来垂直地堆叠我们的主要的控件。最后,整个被包裹在SingleChildScrollView控件中。对于垂直方向上的边距,我们使用SizedBoxes配合height属性来实现我们想要的间距。

让我们一个个浏览每个组件,看看他们是由什么构成的。

MovieDetailHeader 电影详情页面头部

这个MovieDetailHeader是一个简单的Stack控件,包含了两个子控件。Stack是这样的一个容器,子控件能够相互在其上面堆叠地进行布局。第一个子控件是圆弧形背景图像,第二个是Row控件,包含了海报以及电影的相关信息。

因为原模型设计是将电影横幅部分地覆盖在电影信息上,我们在这里使用了一个小技巧:ArcBannerImage的底部填充间距140.0。

这只是延伸了底部可用的空间,因此我们可以部分地将横幅与电影信息覆盖起来。如果没有这个技巧,电影信息就会被放在整个横幅的顶部,从而产生一个相当丑陋的效果:

我们使用Positioned控件来将我们的电影信息固定在底部。电影信息行包含了两项:电影海报和关于电影进一步的明细信息(这是一个Column控件)。

movie_detail_header.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import 'package:flutter/material.dart';
import 'package:movie_details_ui/arc_banner_image.dart';
import 'package:movie_details_ui/models.dart';
import 'package:movie_details_ui/poster.dart';
import 'package:movie_details_ui/rating_information.dart';

class MovieDetailHeader extends StatelessWidget {
MovieDetailHeader(this.movie);
final Movie movie;

List<Widget> _buildCategoryChips(TextTheme textTheme) {
return movie.categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(category),
labelStyle: textTheme.caption,
backgroundColor: Colors.black12,
),
);
}).toList();
}

@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;

var movieInformation = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.title,
style: textTheme.title,
),
SizedBox(height: 8.0),
RatingInformation(movie),
SizedBox(height: 12.0),
Row(children: _buildCategoryChips(textTheme)),
],
);

return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 140.0),
child: ArcBannerImage(movie.bannerUrl),
),
Positioned(
bottom: 0.0,
left: 16.0,
right: 16.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Poster(
movie.posterUrl,
height: 180.0,
),
SizedBox(width: 16.0),
Expanded(child: movieInformation),
],
),
),
],
);
}
}

这个Column控件包含了三个部分(子控件):电影标题,电影评分,电影分类。

有一点需要注意的是,我们这里使用了Expanded控件,否则,如果电影名过长将会被剪切掉,此外还在左边添加了一些填充,这样海报和电影信息就不会紧紧挨着了。

ArcBannerImage 圆弧横幅图片