先看今天的成品會長什麼樣子:
(gif經過壓縮後畫質會降低會有卡卡的感覺,但實際上滑動很順)
接下來就一步步來看這是如何做到的吧。
UI程式碼參考於Devefy Story App UI
Semibold
SF-Pro-Text-Bold
SF-Pro-Text-Regular
下載後放到assets資料夾,並在pubspec.yaml加上引用
HomePage的程式碼如下,基本上和之前的架構差不多,不過為了要使用BlocBuilder
所以必須從Stateless變成Stateful widget。
import 'package:flutter/material.dart';
import '../authentication_bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttube/movie/movie.dart';
import 'show_movie_widget.dart';
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
MovieBloc _movieBloc;
final MovieRepository _movieRepository = MovieRepository();
int _selectIndex = 0;
@override
void initState() {
_movieBloc = MovieBloc(movieRepository: _movieRepository);
_movieBloc.dispatch(FetchTopRated(region: 'TW'));
super.initState();
}
@override
Widget build(BuildContext context) {
void _onTap(int index) {
setState(() {
_selectIndex = index;
});
switch (index) {
case 0:
_movieBloc.dispatch(FetchPopular(region: 'TW'));
break;
case 1:
_movieBloc.dispatch(FetchNowPlaying(region: 'TW'));
break;
case 2:
_movieBloc.dispatch(FetchTopRated(region: 'TW'));
break;
}
}
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF1b1e44),
Color(0xFF2d3447),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
tileMode: TileMode.clamp)),
child: Scaffold(
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: Icon(Icons.star), title: Text('Popular')),
BottomNavigationBarItem(
icon: Icon(Icons.play_circle_filled),
title: Text('Now Playing')),
BottomNavigationBarItem(
icon: Icon(Icons.thumb_up), title: Text('Top Rated')),
],
backgroundColor: Colors.amberAccent,
onTap: _onTap,
currentIndex: _selectIndex,
),
backgroundColor: Colors.transparent,
body: BlocBuilder(
bloc: _movieBloc,
builder: (context, state) {
if (state is LoadingMovie) {
return Center(
child: CircularProgressIndicator(),
);
} else if (state is InitMovieState) {
return Center(
child: Text('Init Movie'),
);
} else if (state is PopularMovieState) {
return ShowMovieWidget(
movieList: state.movieList,
category: 'Popular',
);
} else if (state is NowPlayingMovieState) {
return ShowMovieWidget(
movieList: state.movieList,
category: 'Now Playing',
);
} else if (state is TopRatedMovieState) {
return ShowMovieWidget(
movieList: state.movieList,
category: 'Top Rated',
);
}
return Center(
child: Text('Failed'),
);
},
),
),
);
}
}
程式碼稍微有點長,不過大多都是之前提過的東西,就從上到下挑幾個重點出來。
_onTap
根據使用者點選的button index觸發相對應的Movie Bloc Event。在home資料夾下新增「show_movie_widget.dart」(我知道名字取得很爛><)
這個widget會接收從TMDb Api傳回來的MovieList資料,以及清單種類,並用待會要新增的「card_scroll_widget」達到前面看到的滑動卡片的效果。
程式碼如下:
import '../movie/movie.dart';
import 'package:flutter/material.dart';
import 'card_scroll_widget.dart';
class ShowMovieWidget extends StatefulWidget {
final MovieList _movieList;
final String _category;
ShowMovieWidget(
{Key key, @required MovieList movieList, @override String category})
: _movieList = movieList,
_category = category,
super(key: key);
@override
_ShowMovieWidgetState createState() => _ShowMovieWidgetState();
}
class _ShowMovieWidgetState extends State<ShowMovieWidget> {
var currentPage;
MovieList get movieList => widget._movieList;
String get category => widget._category;
@override
void initState() {
currentPage = movieList.results.length - 1.0;
super.initState();
}
@override
Widget build(BuildContext context) {
PageController controller =
PageController(initialPage: movieList.results.length - 1);
controller.addListener(() {
setState(() {
currentPage = controller.page;
});
});
return Container(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: 12.0, right: 12.0, top: 30.0, bottom: 8.0),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(category,
style: TextStyle(
color: Colors.white,
fontSize: 40.0,
fontFamily: "Calibre-Semibold",
letterSpacing: 1.0,
)),
Align(
alignment: Alignment.centerRight,
child: Image.asset(
'assets/TMDb.png',
height: 40,
),
)
],
),
),
Stack(
children: <Widget>[
CardScrollWidget(
currentPage: currentPage,
movieList: movieList,
),
Positioned.fill(
child: PageView.builder(
itemCount: movieList.results.length,
controller: controller,
reverse: true,
itemBuilder: (context, index) {
return Container();
},
),
),
],
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
FittedBox(
child: Text(movieList.results[currentPage.round()].title,
style: TextStyle(
color: Colors.white,
fontSize: 26.0,
fontFamily: "SF-Pro-Text-Bold")),
),
SizedBox(
width: 10,
),
Text(
"上映日期:${movieList.results[currentPage.round()].releaseDate}",
style: TextStyle(
color: Colors.amberAccent,
fontSize: 10.0,
fontFamily: "SF-Pro-Text-Regular"))
],
)),
],
),
);
}
}
又是100多行的程式碼,不過別擔心,大部分的程式碼都是你看過的。
先介紹Stack
widget,一般我們使用到的widget都是獨立分開沒辦法重疊,如果想要有堆疊的效果就可以使用Stack。在Stack裡可以用Positioned
設定每個widget的位置,也可以調整堆疊的效果。(了解更多細節可以參考Flutter Wiget of the Week)
為了達到滑動頁面的效果,這裡使用PageView
(更多細節可以參考Flutter Wiget of the Week),它需要controller來控制目前的頁面狀況,因此可以藉由監聽controller得到當前pageView的頁數(頁數會是double值,因為有可能停留在兩頁之間),而取得的頁數可以給card_scroll_widget計算目前該顯示哪部電影的海報圖片。
所以在這裡PageView基本上是個工具人,它沒有要顯示任何東西,單純利用它能提供動態頁數的特性來達到滑動電影海報的動畫效果。
在home資料夾下新增「card_scroll_widget.dart」。
CardScrollWidget會需要剛剛PageView提供的頁數值以及要顯示的MovieList作為參數。
接下來會簡單說明它是如何利用計算頁數決定目前該顯示哪些電影海報而哪些該被移出畫面。
先附上程式碼:
import 'package:flutter/material.dart';
import 'dart:math';
import '../movie/movie.dart';
class CardScrollWidget extends StatelessWidget {
final currentPage;
final padding = 20.0;
final verticalInset = 20.0;
final MovieList _movieList;
CardScrollWidget({this.currentPage, MovieList movieList})
: _movieList = movieList;
@override
Widget build(BuildContext context) {
var cardAspectRatio = 12.0 / 16.0;
var widgetAspectRatio = cardAspectRatio * 1.2;
return new AspectRatio(
aspectRatio: widgetAspectRatio,
child: LayoutBuilder(builder: (context, constraints) {
var width = constraints.maxWidth;
var height = constraints.maxHeight;
var safeWidth = width - 2 * padding;
var safeHeight = height - 2 * padding;
var heightOfPrimaryCard = safeHeight;
var widthOfPrimaryCard = heightOfPrimaryCard * cardAspectRatio;
var primaryCardLeft = safeWidth - widthOfPrimaryCard;
var horizontalInset = primaryCardLeft / 2;
List<Widget> cardList = new List();
for (var i = 0; i < _movieList.results.length; i++) {
var delta = i - currentPage;
bool isOnLeft = delta > 0;
var start = padding +
max(
primaryCardLeft - horizontalInset * -delta * (isOnLeft ? 15 : 1),
0.0);
var cardItem = Positioned.directional(
top: padding + verticalInset * max(-delta, 0.0),
bottom: padding + verticalInset * max(-delta, 0.0),
start: start,
textDirection: TextDirection.rtl,
child: ClipRRect(
borderRadius: BorderRadius.circular(16.0),
child: Container(
decoration: BoxDecoration(color: Colors.white, boxShadow: [
BoxShadow(
color: Colors.black12,
offset: Offset(3.0, 6.0),
blurRadius: 10.0)
]),
child: AspectRatio(
aspectRatio: cardAspectRatio,
child: FadeInImage.assetNetwork(
image:
'https://image.tmdb.org/t/p/w185${_movieList.results[i].posterPath}',
placeholder: 'assets/no.jpg',
fit: BoxFit.fill,),
),
),
),
);
cardList.add(cardItem);
}
return Stack(
children: cardList,
);
}),
);
}
}
滑動卡片的秘密是由簡單的數學計算組成的,對於看到數學就不舒服的人可以跳過這段,因為我解釋得滿爛的XD
判斷電影海報該顯示和消失的關鍵值是var delta = i - currentPage;
,i
是電影海報在MovieList中的index,currentPage
是目前的頁數(注意越左邊的頁數值越大,最右邊的值為0,和一般的直覺相反)。
舉個例子:
接下來是start
,start
的值表示widget和起始點距離有多遠(起始點通常是從左到右ltr,不過這裡是從右到左rtl)。
所以start=100
相當於和右邊邊界相差100單位距離,如果單位距離超出螢幕寬度,它就會消失不見。
有了這個觀念後來看start
是如何使用delta計算的。
var start = padding +
max(primaryCardLeft - horizontalInset * -delta * (isOnLeft ? 15 : 1), 0.0);
並不用知道primaryCardLeft
和horizontalInset
是什麼,只要知道這個公式中的delta越大,算出來的start
也會越大,也就是電影海報會在越左邊的地方,然後隨著delta變小又會回到畫面上。
所以實際上我們看到的其實是delta隨著pageView頁數變化不停地更新介面所形成的動畫。
把start值print出來就能很明顯地看到,往右滑越左邊的start值就會越大。
今天使用bottomNavigationBar
結合BlocBuilder
讓我們可以在首頁切換不同的電影清單類型,並且改造了原本單調的gridView,改用更加動態的pageView方式呈現。
藉由Devefy的教學程式碼學會如何做出滑動卡片的效果,如果你有興趣甚至可以結合之前提過的transform
做出更酷的轉場效果(可以參考這邊)。
明天會繼續改造首頁,但不會像今天有這麼多程式碼,所以就明天見吧。
完整程式碼在這裡-> FlutTube Github