#02 Spotify 電子書

洪瑋雪
海大 SwiftUI iOS / Flutter App 程式設計
21 min readApr 5, 2024

Flutter

功能需求

  • 製作包含多個頁面的電子書 App。✅
  • 每個頁面定義對應的 StatelessWidget。✅
class PodcastListView extends StatelessWidget

class PodcastDetailPage extends StatelessWidget

class MusicTab extends StatelessWidget

class MusicDetailPage extends StatelessWidget
  • 定義資料的型別,比方電影 App 定義 Movie 型別。✅
  • 使用到 ListView.builder 或 ListView.separated。✅
child: ListView.builder(
// 列表構建器部件
itemCount: podcasts.length, // 列表項目數量
itemBuilder: (context, index) {
// 列表項目構建器
return GestureDetector(
// 手勢檢測部件
onTap: () {
// 點擊事件處理
Navigator.push(
// 導航到 PodcastDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => PodcastDetailPage(
// PodcastDetailPage 頁面部件
podcast: podcasts[index], // 傳遞選中的 podcast
),
),
);
},
child:
PodcastTile(podcast: podcasts[index]), // PodcastTile 部件
);
},
),
child: ListView.builder(
// 列表構建器部件
scrollDirection: Axis.horizontal, // 設置滾動方向為水平
itemCount: podcasts.length, // 列表項目數量
itemBuilder: (context, index) {
// 列表項目構建器
Episode episode = podcasts[index].episodes[0]; // 選擇第一集作為最新單集
Podcast podcast = podcasts[index]; // 當前 Podcast
return GestureDetector(
// 手勢檢測部件
onTap: () {
// 點擊事件處理
Navigator.push(
// 導航到 EpisodeDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => EpisodeDetailPage(
// EpisodeDetailPage 頁面部件
episode: episode, // 傳遞選擇的 episode
podcast: podcast, // 傳遞當前 podcast
episodeTitle: episode.title, // 添加 episode 的標題
episodeDescription:
episode.description, // 添加 episode 的描述
),
),
);
},
child: Container(
// 容器部件
margin: const EdgeInsets.symmetric(horizontal: 8), // 設置外邊距
child: Column(
// 列部件
crossAxisAlignment: CrossAxisAlignment.start, // 交叉軸對齊方式
children: [
// 子部件列表
ClipRRect(
// 圓角矩形裁剪部件
borderRadius: BorderRadius.circular(8), // 設置圓角半徑
child: Image.network(
// 圖片網絡部件
podcasts[index].imageUrl, // 圖片地址
width: 120, // 設置圖片寬度
height: 120, // 設置圖片高度
fit: BoxFit.cover, // 設置圖片填充方式,以覆蓋整個容器
),
),
const SizedBox(height: 4), // 空白部件,用於設置垂直間距
SizedBox(
// 尺寸限制部件
width: 120, // 設置寬度
height: 15, // 設置高度
child: Text(
// 文本部件
podcasts.isNotEmpty
? podcasts[index].episodes[0].title
: '', // 最新單集標題文本
overflow: TextOverflow.ellipsis, // 文本溢出處理方式
style: const TextStyle(
// 文本樣式
fontSize: 12, // 字體大小
fontWeight: FontWeight.bold, // 字體粗細
),
),
),
const SizedBox(height: 4), // 空白部件,用於設置垂直間距
SizedBox(
// 尺寸限制部件
width: 120, // 設置寬度
height: 30, // 設置高度
child: Text(
// 文本部件
podcasts.isNotEmpty
? podcasts[index].title
: '', // Podcast 標題文本
maxLines: 2, // 設置最大行數
style: const TextStyle(
// 文本樣式
fontSize: 10, // 字體大小
color: Colors.grey, // 字體顏色
),
),
)
],
),
),
);
},
),
  • 自訂 StatelessWidget 顯示 ListView 的資料,比方用 BookTile 顯示 Book 的內容。✅
import 'package:f/main.dart'; // 導入 main.dart 文件
import 'package:flutter/material.dart'; // 導入 flutter 庫

class PodcastTile extends StatelessWidget {
// Podcast磁貼部件
final Podcast podcast; // Podcast對象

const PodcastTile({super.key, required this.podcast}); // 構造函數

@override
Widget build(BuildContext context) {
// 構建函數
return Card(
// 卡片部件
child: ListTile(
// 列表磚部件
leading: Image.network(podcast.imageUrl), // 前導圖像部件
title: Text(podcast.title), // 標題文本部件
subtitle: Text(podcast.description), // 副標題文本部件
),
);
}
}
  • 使用 Navigator 切換頁面。✅
 Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
MusicDetailPage(music: musicList[index]), // 新页面构建函数
),
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EpisodeDetailPage(
// 生成 EpisodeDetailPage 部件
episode: episode, // 传递选择的 episode
podcast: podcast, // 传递当前 podcast
episodeTitle: episode.title, // 添加 episode 的标题
episodeDescription:
episode.description, // 添加 episode 的描述
),
),
);
Navigator.push(
// 導航到 PodcastDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => PodcastDetailPage(
// PodcastDetailPage 頁面部件
podcast: podcasts[index], // 傳遞選中的 podcast
),
),
);
Navigator.push(
// 導航到 EpisodeDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => EpisodeDetailPage(
// EpisodeDetailPage 頁面部件
episode: episode, // 傳遞選擇的 episode
podcast: podcast, // 傳遞當前 podcast
episodeTitle: episode.title, // 添加 episode 的標題
episodeDescription:
episode.description, // 添加 episode 的描述
),
),
);
  • 使用 GestureDetector、InkWell 或 TextButton 偵測點擊。✅
return GestureDetector(
// 手势检测部件
onTap: () {
// 点击事件处理
// 导航到新页面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
MusicDetailPage(music: musicList[index]), // 新页面构建函数
),
);
},
child: MusicCard(music: musicList[index]), // 音乐卡片部件
);
return GestureDetector(
// 手势检测部件
onTap: () {
// 点击事件处理
// 导航到 EpisodeDetailPage 并传递相应的 episode
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EpisodeDetailPage(
// 生成 EpisodeDetailPage 部件
episode: episode, // 传递选择的 episode
podcast: podcast, // 传递当前 podcast
episodeTitle: episode.title, // 添加 episode 的标题
episodeDescription:
episode.description, // 添加 episode 的描述
),
),
);
},
child: Padding(
// 填充部件
padding:
const EdgeInsets.symmetric(vertical: 8.0), // 设置填充边距
child: Column(
// 列部件
crossAxisAlignment: CrossAxisAlignment.start, // 交叉轴对齐方式
children: <Widget>[
// 子部件列表
Row(
// 行部件
crossAxisAlignment:
CrossAxisAlignment.center, // 交叉轴对齐方式
children: [
// 子部件列表
ClipRRect(
// 圆角矩形裁剪部件
borderRadius:
BorderRadius.circular(10), // 设置圆角半径
child: Image.network(
// 图片网络部件
podcast.imageUrl, // 图片地址
width: 60, // 设置图片宽度
height: 60, // 设置图片高度
fit: BoxFit.cover, // 设置图片填充方式,以覆盖整个容器
),
),
const SizedBox(width: 20), // 空白部件,用于设置水平间距
Expanded(
// 扩展部件
child: Column(
// 列部件
crossAxisAlignment:
CrossAxisAlignment.start, // 交叉轴对齐方式
children: [
// 子部件列表
Text(
// 文本部件
episode.title, // 标题文本
style: const TextStyle(
// 文本样式
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 5), // 空白部件,用于设置垂直间距
],
),
),
],
),
const SizedBox(height: 10), // 空白部件,用于设置垂直间距
SizedBox(
// 尺寸限制部件
height: 40, // 设置高度
child: Expanded(
// 扩展部件
child: Text(
// 文本部件
episode.description, // 描述文本
maxLines: 2, // 设置最大行数
overflow: TextOverflow.ellipsis, // 超出部分用省略号表示
style: const TextStyle(fontSize: 14), // 文本样式
),
),
),
const SizedBox(height: 15), // 空白部件,用于设置垂直间距
const Row(
// 行部件
children: [
// 子部件列表
Icon(Icons.add_circle_outline), // 添加图标部件
SizedBox(width: 10), // 空白部件,用于设置水平间距
Icon(Icons.download), // 下载图标部件
SizedBox(width: 10), // 空白部件,用于设置水平间距
Icon(Icons.share), // 分享图标部件
SizedBox(width: 10), // 空白部件,用于设置水平间距
Icon(Icons.more_vert), // 更多图标部件
Spacer(), // 空白部件,用于撑开剩余空间
Icon(Icons.play_circle_outline), // 播放图标部件
],
),
const SizedBox(height: 10), // 空白部件,用于设置垂直间距
const Divider(
// 分隔线部件
color: Colors.grey, // 设置颜色
thickness: 0.6, // 设置厚度
),
],
),
),
);
return GestureDetector(
// 手勢檢測部件
onTap: () {
// 點擊事件處理
Navigator.push(
// 導航到 PodcastDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => PodcastDetailPage(
// PodcastDetailPage 頁面部件
podcast: podcasts[index], // 傳遞選中的 podcast
),
),
);
},
child:
PodcastTile(podcast: podcasts[index]), // PodcastTile 部件
);
return GestureDetector(
// 手勢檢測部件
onTap: () {
// 點擊事件處理
Navigator.push(
// 導航到 EpisodeDetailPage 頁面
context,
MaterialPageRoute(
builder: (context) => EpisodeDetailPage(
// EpisodeDetailPage 頁面部件
episode: episode, // 傳遞選擇的 episode
podcast: podcast, // 傳遞當前 podcast
episodeTitle: episode.title, // 添加 episode 的標題
episodeDescription:
episode.description, // 添加 episode 的描述
),
),
);
},
child: Container(
// 容器部件
margin: const EdgeInsets.symmetric(horizontal: 8), // 設置外邊距
child: Column(
// 列部件
crossAxisAlignment: CrossAxisAlignment.start, // 交叉軸對齊方式
children: [
// 子部件列表
ClipRRect(
// 圓角矩形裁剪部件
borderRadius: BorderRadius.circular(8), // 設置圓角半徑
child: Image.network(
// 圖片網絡部件
podcasts[index].imageUrl, // 圖片地址
width: 120, // 設置圖片寬度
height: 120, // 設置圖片高度
fit: BoxFit.cover, // 設置圖片填充方式,以覆蓋整個容器
),
),
const SizedBox(height: 4), // 空白部件,用於設置垂直間距
SizedBox(
// 尺寸限制部件
width: 120, // 設置寬度
height: 15, // 設置高度
child: Text(
// 文本部件
podcasts.isNotEmpty
? podcasts[index].episodes[0].title
: '', // 最新單集標題文本
overflow: TextOverflow.ellipsis, // 文本溢出處理方式
style: const TextStyle(
// 文本樣式
fontSize: 12, // 字體大小
fontWeight: FontWeight.bold, // 字體粗細
),
),
),
const SizedBox(height: 4), // 空白部件,用於設置垂直間距
SizedBox(
// 尺寸限制部件
width: 120, // 設置寬度
height: 30, // 設置高度
child: Text(
// 文本部件
podcasts.isNotEmpty
? podcasts[index].title
: '', // Podcast 標題文本
maxLines: 2, // 設置最大行數
style: const TextStyle(
// 文本樣式
fontSize: 10, // 字體大小
color: Colors.grey, // 字體顏色
),
),
)
],
),
),
);
  • 使用 GridView 製作格子狀排列的畫面。✅
return GridView.builder(
// 网格构建器部件
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
// 网格代理部件
crossAxisCount: 2, // 每行显示的列数
crossAxisSpacing: 10.0, // 列之间的间距
mainAxisSpacing: 10.0, // 行之间的间距
childAspectRatio: 0.7, // 子元素的宽高比
),
itemCount: musicList.length, // 列表项数量
itemBuilder: (BuildContext context, int index) {
// 列表项构建函数
return GestureDetector(
// 手势检测部件
onTap: () {
// 点击事件处理
// 导航到新页面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
MusicDetailPage(music: musicList[index]), // 新页面构建函数
),
);
},
child: MusicCard(music: musicList[index]), // 音乐卡片部件
);
},
);
  • 使用 DefaultTabController、TabBar、TabBarView 製作 tab 分頁。
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Spotify'),
bottom: const TabBar(
tabs: [
Tab(text: 'Podcast'), // Podcast 標籤
Tab(text: 'Music'), // 音樂標籤
],
),
),
body: const TabBarView(
children: [
PodcastListView(), // Podcast 列表視圖
MusicTab(), // 音樂分頁
],
),
),
);
bottom: const TabBar(
tabs: [
Tab(text: 'Podcast'), // Podcast 標籤
Tab(text: 'Music'), // 音樂標籤
],
),
body: const TabBarView(
children: [
PodcastListView(), // Podcast 列表視圖
MusicTab(), // 音樂分頁
],
),
  • 使用 Card widget。✅
return Card(
// 卡片部件
elevation: 3, // 陰影
child: Padding(
// 內邊距
padding: const EdgeInsets.all(8.0), // 四周內邊距
child: Column(
// 列部件
crossAxisAlignment: CrossAxisAlignment.start, // 水平方向左對齊
children: [
Expanded(
// 擴展部件
child: ClipRRect(
// 裁剪矩形部件
borderRadius: BorderRadius.circular(8.0), // 圓角半徑
child: Image.network(
// 網絡圖片部件
music.imageUrl, // 圖片 URL
fit: BoxFit.cover, // 裁剪類型
),
),
),
const SizedBox(height: 8.0), // 高度間距
Text(
// 文本部件:標題
music.title, // 標題
style: const TextStyle(
// 文本樣式
fontWeight: FontWeight.bold, // 字體粗細
),
maxLines: 2, // 最大行數
overflow: TextOverflow.ellipsis, // 文本溢出處理
),
const SizedBox(height: 4.0), // 高度間距
Text(
// 文本部件:藝術家
music.artist, // 藝術家
style: const TextStyle(
// 文本樣式
color: Colors.grey, // 文本顏色
),
maxLines: 1, // 最大行數
overflow: TextOverflow.ellipsis, // 文本溢出處理
),
],
),
),
);
return Card(
// 卡片部件
child: ListTile(
// 列表磚部件
leading: Image.network(podcast.imageUrl), // 前導圖像部件
title: Text(podcast.title), // 標題文本部件
subtitle: Text(podcast.description), // 副標題文本部件
),
);

--

--