Apple 在 WWDC19 介紹了最新的開發框架,其中之一就是 SwiftUI 以及 Combine。如果你還沒有知道這個消息,簡單來說,SwiftUI 是一種新的方法,讓我們可以藉由宣告方式來創建 UI;而 Combine 是與它一起使用的,Combine 提供了宣告式 Swift API,以處理像是 UI 或是 Network 事件的值。
如果你是剛接觸 SwiftUI,我推薦你可以閱讀這篇文章來瞭解它的基礎概念。
在這次的專案之中,我們將會應用 SwiftUI,來建立一個電影預告片 App。
在建立這個 App 的過程之中,我們可以學習到:
- 如何從資源檔案載入 JSON 數據
- 如何使用
ForEach
、ActionSheet
、Image
、以及像是Rectangle
的形狀 - 如何使用 UI 元件,像是
ScrollView
、TabView
、VStack
、HStack
、ZStack
、NavigationLink
、Button 等 - 如何使用
Property Observers
,像是State
、Binding
等 - 如何透過
UIViewRepresentable
和UIViewControllerRepresentable
,使用 SwiftUI 中的 UIKit ViewControllers - 如何使用
SFSafariViewController
、UIPageViewController
1. 創建 Model 類別與 JSON 檔案
import SwiftUI
struct Movie: Hashable, Codable, Identifiable {
var id: Int
var thumbnail: String
var title: String
var description: String
var trailerLink: String
var catagory: Catagory
var isFeaturedMovie: Bool
enum Catagory: String, CaseIterable, Codable, Hashable {
case marvel = "Marvel"
case dc = "DC"
case actionAdventure = "Action and adventure"
}
}
從上面的程式碼中,我創建了一個 Model 類別,裡面包含了每個電影的詳細資料,像是:id、 thumbnail、title、description、trailerLink、catagory 等等。
接著,我創建了一個 Catagory,幫助我們以電影類型將電影分類。
[
{
"id": 1,
"thumbnail": "aquaman",
"title": "Aquaman",
"description": "Once home to the most advanced civilization on Earth, the city of Atlantis is now an underwater kingdom ruled by the power-hungry King Orm. With a vast army at his disposal, Orm plans to conquer the remaining oceanic people -- and then the surface world. Standing in his way is Aquaman, Orm's half-human, half-Atlantean brother and true heir to the throne. With help from royal counselor Vulko, Aquaman must retrieve the legendary Trident of Atlan and embrace his destiny as protector of the deep.",
"trailerLink" : "https://www.youtube.com/watch?v=WDkg3h8PCVU",
"catagory": "DC",
"isFeaturedMovie": false
},
{
"id": 2,
"thumbnail": "avengers",
"title": "The Avengers",
"description": "S.H.I.E.L.D. leader Nick Fury is compelled to launch the Avengers Initiative when Loki poses a threat to planet Earth. His squad of superheroes put their minds together to accomplish the task.",
"trailerLink" : "https://www.youtube.com/watch?v=eOrNdBpGMv8",
"catagory": "Marvel",
"isFeaturedMovie": true
},
{
"id": 3,
"thumbnail": "avengersultron",
"title": "Avengers - Age Of Ultron",
"description": "Tony Stark builds an artificial intelligence system named Ultron with the help of Bruce Banner. When the sentient Ultron makes plans to wipe out the human race, the Avengers set out to stop him.",
"trailerLink" : "https://www.youtube.com/watch?v=tmeOjFno6Do",
"catagory": "Marvel",
"isFeaturedMovie": true
},
{
"id": 4,
"thumbnail": "batmanvssuperman",
"title": "Batman v Superman: Dawn of Justice",
"description": "Bruce Wayne, a billionaire, believes that Superman is a threat to humanity after his battle in Metropolis. Thus, he decides to adopt his mantle of Batman and defeat him once and for all.",
"trailerLink" : "https://www.youtube.com/watch?v=fis-9Zqu2Ro",
"catagory": "DC",
"isFeaturedMovie": false
},
{
"id": 5,
"thumbnail": "infinitywar",
"title": "Avengers: Infinity War",
"description": "A paragraph is a series of sentences that are organized and coherent, and are all related to a single topic. Almost every piece of writing you do that is longer than a few sentences should be organized into paragraphs",
"trailerLink" : "https://www.youtube.com/watch?v=6ZfuNTqbHE8",
"catagory": "Marvel",
"isFeaturedMovie": true
},
{
"id": 6,
"thumbnail": "endgame",
"title": "Avengers: Endgame",
"description": "Adrift in space with no food or water, Tony Stark sends a message to Pepper Potts as his oxygen supply starts to dwindle. Meanwhile, the remaining Avengers -- Thor, Black Widow, Captain America and Bruce Banner -- must figure out a way to bring back their vanquished allies for an epic showdown with Thanos -- the evil demigod who decimated the planet and the universe.",
"trailerLink" : "https://www.youtube.com/watch?v=TcMBFSGVi1c",
"catagory": "Marvel",
"isFeaturedMovie": true
},
{
"id": 7,
"thumbnail": "justiceleague",
"title": "Justice League",
"description": "Steppenwolf and his Parademons set out to take over the Earth. However, Batman seeks the help of Wonder Woman to assemble and recruit Flash, Cyborg and Aquaman to thwart the powerful new enemy.",
"trailerLink" : "https://www.youtube.com/watch?v=jrAA9Gt-jqw",
"catagory": "DC",
"isFeaturedMovie": false
},
{
"id": 8,
"thumbnail": "wonderwomen",
"title": "Wonder Women",
"description": "Princess Diana of an all-female Amazonian race rescues US pilot Steve. Upon learning of a war, she ventures into the world of men to stop Ares, the god of war, from destroying mankind.",
"trailerLink" : "https://www.youtube.com/watch?v=VSB4wGIdDwo",
"catagory": "DC",
"isFeaturedMovie": false
},
{
"id": 9,
"thumbnail": "venom",
"title": "Venom",
"description": "While trying to take down Carlton, the CEO of Life Foundation, Eddie, a journalist, investigates experiments of human trials. Unwittingly, he gets merged with a symbiotic alien with lethal abilities.",
"trailerLink" : "https://www.youtube.com/watch?v=u9Mv98Gr5pY",
"catagory": "Action and adventure",
"isFeaturedMovie": false
},
{
"id": 10,
"thumbnail": "mib",
"title": "Men in Black: International",
"description": "The Men in Black have expanded to cover the globe but so have the villains of the universe. To keep everyone safe, decorated Agent H and determined rookie M join forces -- an unlikely pairing that just might work. When aliens that can take the form of any human arrive on Earth, H and M embark on a globe-trotting adventure to save the agency -- and ultimately the world -- from their mischievous plans.",
"trailerLink" : "https://www.youtube.com/watch?v=BV-WEb2oxLk",
"catagory": "Action and adventure",
"isFeaturedMovie": false
},
{
"id": 11,
"thumbnail": "fallout",
"title": "Mission: Impossible – Fallout",
"description": "A group of terrorists plans to detonate three plutonium cores for a simultaneous nuclear attack on different cities. Ethan Hunt, along with his IMF team, sets out to stop the carnage.",
"trailerLink" : "https://www.youtube.com/watch?v=wb49-oV0F78",
"catagory": "Action and adventure",
"isFeaturedMovie": false
},
{
"id": 12,
"thumbnail": "spiderman",
"title": "Spider-Man: Into the Spider-Verse",
"description": "Teen Miles Morales becomes Spider-Man of his reality, crossing his path with five counterparts from another dimensions to stop a threat for all realities.",
"trailerLink" : "https://www.youtube.com/watch?v=XfJ1PFzE8DU",
"catagory": "Action and adventure",
"isFeaturedMovie": false
}
]
這個 Movies.json 檔案包含了一個我創建的的靜態 json,用於開發這個電影 App。
JSON 解碼
import Foundation
let moviesData:[Movie] = load("movies.json")
func load<T:Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}
我使用 JSONDecoder()
將 JSON 從資源檔案 movies.json 解析出來。現在,我們可以在任何包含了電影資料陣列的 Views
中使用 moviesData。
2. 儀表板 (Dashboard)
在這個章節中,我們會開發一個電影預告片 App 的儀表板,成果看起來會像這樣:
電影項目的設計
import SwiftUI
struct MovieItem : View {
var movie: Movie
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Image(movie.thumbnail)
.resizable()
.renderingMode(.original)
.aspectRatio(contentMode: .fill)
.frame(width: 300, height: 170)
.clipped()
.cornerRadius(10)
.shadow(radius: 10)
VStack(alignment: .leading, spacing: 5) {
Text(movie.title)
.foregroundColor(.primary)
.font(.headline)
Text(movie.description)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(2)
.frame(height: 40)
}
}
}
}
struct MovieItem_Preview: PreviewProvider {
static var previews: some View {
MovieItem(movie: moviesData.first!)
}
}
我使用了 VStack
來將 Image
與電影資料的描述對齊,例如 Title 以及 Description。
以下是 Xcode 對於特定電影項目的預覽:
設計橫列 (Row) 的電影項目
import SwiftUI
struct MovieRow : View {
var catagoryName: String
var movies: [Movie]
var body: some View {
VStack(alignment: .leading) {
Text(self.catagoryName)
.font(.title)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top) {
ForEach(self.movies,id: \.title) { movie in
NavigationLink(destination: MovieDetail(movie: movie)) {
MovieItem(movie: movie)
.frame(width: 300)
.padding(.trailing, 30)
.cornerRadius(10)
.clipped()
}
}
}
}
}
}
}
struct MovieRow_Preview: PreviewProvider {
static var previews: some View {
MovieRow(catagoryName: "Marvel", movies: load("movies.json"))
}
}
我使用了 VStack
將 MovieItems 與 Movie Catagory title 垂直對齊。然後,再使用 ScrollView
將每一個 MovieItem 水平對齊,並形成一個橫列。另外,我再使用 NavigationLink
,引導使用者到呈現電影詳細資料的 MovieDetail 頁面。
我也使用了 ForEach
來將 MovieItem 水平對齊,按陣列元素組成一個橫列的電影項目。
設計建基於電影類型的 Movie Row
import SwiftUI
import Combine
struct HomeView : View {
var catagories: [String: [Movie]] {
.init(grouping: moviesData,
by: {$0.catagory.rawValue}
)
}
var body: some View {
NavigationView {
ScrollView(.vertical , showsIndicators: false) {
VStack {
ForEach(catagories.keys.sorted(), id: \String.self) { key in
MovieRow(catagoryName: key, movies: self.catagories[key]!)
.frame(height: 320)
.padding(.top)
.padding(.bottom)
}
}
.padding()
.navigationBarTitle(Text("MOVIES").bold())
}
}
}
struct HomeView_Preview: PreviewProvider {
static var previews: some View {
HomeView()
}
}
我利用了 Grouping
和 filtering
,依照電影類別來創建一個陣列,我們也向它提供 NavigationView
,給儀表板設標題,同時也可以在視圖之間進行導航。
我們藉由使用 Catagories 的 ForEach
和 Keys
,創建電影類別的橫列。
現在,我們已經為每種電影類別創建了一個 MovieRow。每個類別都有一個元素的陣列,讓 MovieItem 可以填入不同的 MovieRow 之中。
ForEach(catagories.keys.sorted(), id: \String.self) { key in MovieRow(catagoryName: key,movies: self.catagories[key]!) .frame(height: 320)
.padding(.top)
.padding(.bottom)
}
使用 UIKit 來建構介面 — 設計精選電影 (Featured Movie)
SwiftUI 能與所有 Apple 平台現有的 UI 框架完美協作。舉例來說,你可以將 UIKit 的視圖和視圖控制器放到它的視圖裡面,反之亦然。
這部分的教學中,我會向你展示如何將精選電影清單由主選單轉換成 UIPageViewController
與 UIPageControl
的包裝實例。
你將會使用 UIPageViewController
來展示 SwiftUI 視圖的輪播,並使用狀態變數與綁定,來協調使用者介面中的數據更新。
1) 創建呈現 UIPageViewController 的視圖
為了在 SwiftUI 中呈現 UIKit 視圖與視圖控制器,我們需要創建類別來遵循 UIViewRepresentable
和 UIViewControllerRepresentable
協定。
根據開發者自定義的類別,創建並配置它們所呈現的 UIKit 類別,SwiftUI 就會管理其生命週期,並適時更新。
步驟 1:創建一個新的 SwiftUI 視圖檔案,命名為 PageViewController.swift
。接著,宣告 PageViewController
類別,並遵循 UIViewControllerRepresentable
。
頁面視圖控制器儲存了一個 UIViewController
實例的陣列。以下是用來在電影之間滾動的頁面。
import SwiftUI
import UIKit
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
}
然後,添加兩個 UIViewControllerRepresentable
協定所需要的實作。
步驟 2:加入一個 makeUIViewController(context:)
方法,以建立一個 UIPageViewController
並搭配所需要的配置。
當 SwiftUI 準備好顯示視圖的時候,就會呼叫一次這個方法,並接手管理視圖控制器的生命週期。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController( transitionStyle: .scroll, navigationOrientation: .horizontal)
return pageViewController
}
步驟 3:加入一個 updateUIViewController(_:context:)
方法,它會呼叫 setViewControllers(_:direction:animated:)
來呈現在陣列之中的第一個視圖控制器。
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}
步驟 4:創建一個新的 SwiftUI 視圖檔案,命名為 PageView.swift
,並更新 PageView
類別來宣告 PageViewController
為子視圖。
- 請注意,泛型建構器 (generic initializer) 取用了一組視圖陣列,並將每一個視圖嵌套到
UIHostingController
裡。 UIHostingController
是UIViewController
的一個子類別,代表著一個在 UIKit 上下文之中的視圖。
import SwiftUI
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0)
}
}
var body: some View {
PageViewController(controllers: viewControllers)
}
}
步驟 5:在進入下一步之前,請將 PageView
預覽固定在畫布上 ── 這個 View 就是我們所有動作執行的所在地。
struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(featuredMovies.map { **FeaturedMovieView**(movie: $0) }) .frame(height: 225)
}
}
步驟 6:為了查看預覽,我們需要創建一個設計來顯示精選電影。我已經創建了一個像圖卡的結構視圖,其中包含了以 ZStack 對齊的標題及縮圖。你可以參考以下的程式碼片段:
struct FeaturedMovieView: View {
var movie: Movie
var body: some View {
ZStack(alignment: .bottom) {
Image(movie.thumbnail)
.resizable()
.clipped()
Rectangle()
.frame(height: 80)
.opacity(0.35)
.blur(radius: 10)
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(movie.title)
.foregroundColor(.white)
.bold()
.font(.largeTitle)
}
.padding(.leading)
.padding(.bottom)
Spacer()
}
}
}
}
struct FeaturedMovieView_Preview: PreviewProvider {
static var previews: some View {
FeaturedMovieView(movie: moviesData.first!)
}
}
2) 建立視圖控制器的資料來源
PageViewController
使用了 UIPageViewController
來呈現 SwiftUI 視圖中的內容,現在是時候來實作切換頁面的滑動功能了。
一個呈現 UIKit 視圖控制器的 SwiftUI 視圖,能夠定義它管理的 Coordinator
類型,並提供一部分的視圖內容。
步驟 1:在 PageViewController
裡頭宣告一個巢狀的 Coordinator
類別。
- SwiftUI 管理了
UIViewControllerRepresentable
類別的協調者 (coordinator),並在上文創建的方法被呼叫時,提供它為內容的一部分。
class Coordinator: NSObject {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
步驟 2:加入另一個方法到 PageViewController
,以創造協調者。
- SwiftUI 會在
makeUIViewController(context:)
之前呼叫這個makeCoordinator()
方法,這麼一來,你就能在配置視圖控制器時存取協調者物件。 - 你可以使用這個協調者來實作常見的 Cocoa 設計模式,像是委派 (delegates)、數據來源 (data sources)、或是透過 target-action 的方式來回應使用者事件。
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
步驟 3:使 Coordinator
類別遵循 UIPageViewControllerDataSource
協定,並實作兩個必要的方法。
- 這兩種方法建立了各個視圖控制器之間的關係,如此一來你就能在它們之間前後滑動。
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
self.parent.currentPage = 0
return parent.controllers.last
}
self.parent.currentPage = index
return parent.controllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController,viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
self.parent.currentPage = index
return parent.controllers.first
}
if index == parent.controllers.count {
self.parent.currentPage = parent.controllers.startIndex
}
self.parent.currentPage = index
return parent.controllers[index + 1]
}
}
步驟 4:加入協調者為 UIPageViewController
的數據來源。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
return pageViewController
}
3) 追蹤在 SwiftUI 視圖狀態中的頁面
為了準備加入客製化的 UIPageControl
,你需要一個方法,讓你可以追蹤當前在 PageView
內的頁面。
為此,你會在 PageView
裡宣告 @State
屬性,並將該屬性的綁定傳遞給 PageViewController
視圖。PageViewController
會更新綁定以符合看得見的頁面。
步驟 1:首先,加入一個 currentPage
綁定作為 PageViewController
的屬性。
- 除了宣告
@Binding
屬性以外,你也要更新setViewControllers(_:direction:animated:)
的呼叫,並將數值傳遞給currentPage
綁定。
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int............ func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers([controllers[currentPage]], direction: .forward, animated: true)................ }
}
步驟 2:在 PageView
裡面宣告 @State
變數,並在創建 PageViewController
時將綁定傳遞給屬性。
- 使用
$
符號來創建一個數值為儲存狀態的綁定。
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0
init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}
步驟 3:加入一個帶有 currentPage
屬性的文字視圖,那麼你就能關注著 @State
屬性的數值。
你可以觀察到當你在頁面之間滑動時,數值並沒有改變。
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0................
var body: some View {
VStack {
PageViewController(controllers: viewControllers,currentPage: $currentPage)
Text("Current Page: \(currentPage)")
}
}
}
步驟 4:在 PageViewController.swift
檔案裡面,讓 Coordinator 類別遵循 UIPageViewControllerDelegate
協定,並加入 pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool)
方法。
- 因為每當頁面切換動畫完成時,SwiftUI 就會呼叫這個方法。你可以找到當前視圖控制器的索引並更新綁定。
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController................. func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of:visibleViewController)
{
parent.currentPage = index
}
}
}
步驟 5:除了資料來源之外,將協調者指派為 UIPageViewController
的委派。
- 在雙向連結都綁定的情況下,每次滑動頁面之後,文字視圖就會更新並且展示正確的頁碼。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
看看以下關於 PageViewController 的程式碼片段:
import SwiftUI
import UIKit
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
self.parent.currentPage = 0
return parent.controllers.last
}
self.parent.currentPage = index
return parent.controllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController,viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
self.parent.currentPage = index
return parent.controllers.first
}
if index == parent.controllers.count {
self.parent.currentPage = parent.controllers.startIndex
}
self.parent.currentPage = index
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
4) 加入客製化的頁面控制 (Page Control)
現在我們可以將客製的 UIPageControl
加入到視圖當中,並且封裝到 SwiftUI 的 UIViewRepresentable
視圖當中。
步驟 1:創建一個新的 SwiftUI 視圖檔案,命名為 PageControl.swift
。更新一下 PageControl
型別,使它遵循 UIViewRepresentable
協定。
UIViewRepresentable
和UIViewControllerRepresentable
都擁有同樣的生命週期,其方法都會對應到底層的 UIKit 類型。
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
}
步驟 2:將文字框替換成頁面控制,以及將 VStack
切換成 ZStack
來進行佈局。
- 因為你將頁面數目及綁定傳遞給當前的頁面,所以頁面控制已經呈現了正確的數值。
import SwiftUI
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0
init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
.padding(.trailing)
}
}
}
- 接著,使頁面控制具有互動性,那麼使用者就能夠透過輕輕點擊某一邊來在頁面之間做切換。
步驟 3:在 PageControl
裡創建一個 Coordinator
巢狀型別,並加入 makeCoordinator()
方法來創建與回傳一個新的協調者。
- 因為
UIControl
的子類別(像是UIPageControl
)使用了 target-action 模式,而非委派模式,所以這個Coordinator
實作了一個@objc
方法來更新當前的頁面綁定。
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}
步驟 4:加入協調者為 valueChanged
事件的目標,並指定 updateCurrentPage(sender:)
方法為需要執行的動作。
struct PageControl: UIViewRepresentable {
.............. func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(context.coordinator, action: #selector(Coordinator.updateCurrentPage(sender:)), for: .valueChanged)
return control
}
}
步驟 5:現在嘗試一下所有不同的互動吧!PageView
展示了 UIKit 與 SwiftUI 視圖和控制器的協同工作。看看以下的程式碼片段,確認 PageControl 的完整程式碼:
import SwiftUI
import UIKit
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}
5) 將精選電影整合到儀表板中
現在,我們需要將精選電影清單加入到儀表板當中。為此,我們要先從電影資料中過濾出精選電影:
var featuredMovies = moviesData.filter({$0.isFeaturedMovie == true})
接著,我們將每個精選電影映射到 Pageview 之中。Pageview 包含了可以滾動瀏覽每套電影的頁面控制。
PageView(featuredMovies.map { FeaturedMovieView(movie: $0) })
參考下列的程式碼片段,將 FeaturedMovies 清單加入到儀表板的頂端:
struct HomeView : View {
var catagories: [String: [Movie]] {
.init(grouping: moviesData,
by: {$0.catagory.rawValue}
)
}
var featuredMovies = moviesData.filter({$0.isFeaturedMovie == true})
var body: some View {
NavigationView {
ScrollView(.vertical , showsIndicators: false) {
VStack {
NavigationLink(destination: MovieDetail(movie: featuredMovies[0])) {
PageView(featuredMovies.map { FeaturedMovieView(movie: $0) })
.frame(height: 225)
}
}
.navigationBarTitle(Text("MOVIES").bold())
}
}
}
}
struct HomeView_Preview: PreviewProvider {
static var previews: some View {
HomeView()
}
}
我們加入 Movie Row 的程式碼,在精選電影下方按電影類型顯示電影:
import SwiftUI
import Combine
struct HomeView : View {
var catagories: [String: [Movie]] {
.init(grouping: moviesData,
by: {$0.catagory.rawValue}
)
}
var featuredMovies = moviesData.filter({$0.isFeaturedMovie == true})
var body: some View {
NavigationView {
ScrollView(.vertical , showsIndicators: false) {
VStack {
NavigationLink(destination: MovieDetail(movie: featuredMovies[0])) {
PageView(featuredMovies.map { FeaturedMovieView(movie: $0) })
.frame(height: 225)
}
VStack {
ForEach(catagories.keys.sorted(), id: \String.self) { key in
MovieRow(catagoryName: key, movies: self.catagories[key]!)
.frame(height: 320)
.padding(.top)
.padding(.bottom)
}
}
.padding()
}
.navigationBarTitle(Text("MOVIES").bold())
}
}
}
}
struct HomeView_Preview: PreviewProvider {
static var previews: some View {
HomeView()
}
}
6) 在儀表板加入 TabView
TabView
利用互動式的使用者介面元素,在多個子視圖之間做切換。
為了使用 Tab 來創建使用者頁面,讓我們將視圖放到一個 TabView
之中,並將 tabItem(_:)
修飾符加入到每一個 Tab 的內容中。下面的程式碼就創建了一個有四個 Tab 的 Tabview:
import SwiftUI
struct DashboardView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Image(systemName: "square.grid.2x2.fill")
Text("Home")
} .tag(0)
HomeView()
.tabItem {
Image(systemName: "video.fill")
Text("TV Shows")
.font(.headline)
} .tag(1)
HomeView()
.tabItem {
Image(systemName: "tv.fill")
Text("Movies")
} .tag(2)
HomeView()
.tabItem {
Image(systemName: "house.fill")
Text("Kids")
} .tag(3)
}
}
}
struct DashboardView_Preview: PreviewProvider {
static var previews: some View {
DashboardView()
}
}
- 每一個 Tab Item 都由不同的
Image
與Text
視圖來客製化。 - Apple 在 WWDC 2019 推出了 SF Symbols App,讓我們能夠以瀏覽這個 App 的方式來使用
Image
。基本上,Apple 提供了免費符號讓我們在 App 中使用,而且使用上也非常容易。 - SF Symbols 支援 iOS 13+、watchOS 6+、以及 tvOS 13+ 平台。
- Apple 提供了 SF Symbols App,讓你可以瀏覽、複製、和匯出任何可用的符號。你可以在這裡下載 App,它可以在 macOS 10.14 或更新版本上運行。
7) 導航到電影細節視圖 (Movie Detail View)
首先,我們需要將 List
嵌入到 NavigationView
裡面,而且我已經在設計 HomeView 的時候完成了:
NavigationView {
........
.......
.navigationBarTitle(Text("MOVIES").bold())
}
.....
這就像是將視圖控制器嵌入到一個導航控制器當中:你現在可以獲取所有導航的內容,像是導航列標題。請注意,.navigationBarTitle
是修飾 List
的,而不是 NavigationView
。你可以在一個 NavigationView
內宣告多於一個視圖,而且每個視圖都能擁有自己的 .navigationBarTitle
。
我們將會預設得到一個較大的 MOVIES 標題。
Creating a Navigation Link
創造一個導航連結 (NevigationLink)
NavigationView
也啟用了 NavigationLink
,它需要一個 destination
視圖和標籤。
在 HomeView
裡的 List
閉包中,使橫列視圖 Text()
變成一個 NavigationLink
按鈕。
NavigationLink(destination: MovieDetail(movie: featuredMovies[0])) {
PageView(featuredMovies.map { FeaturedMovieView(movie: $0) })
.frame(height: 225)
}
ForEach(self.movies,id: \.title) { movie in
NavigationLink(destination: MovieDetail(movie: movie)) {
MovieItem(movie: movie)
......
}
}
3. 電影細節畫面 (Movie Detail Screen)
在這個章節中,我們會開發電影細節畫面,成果看起來會像這樣:
設計預告片按鈕
struct WatchButton: View {
let movie: Movie
@State var showingDetail = false
var body: some View {
Button(action: {
self.showingDetail.toggle()
}) {
Text("Watch Trailer")
.frame(width: 200, height: 50,alignment: .center)
.foregroundColor(.white)
.font(.headline)
.background(Color.blue)
.cornerRadius(10)
} .sheet(isPresented: $showingDetail){
TrailerView(movie: self.movie)
}
}
}
struct WatchButton_Preview: PreviewProvider {
static var previews: some View {
WatchButton(movie: moviesData.first!)
}
}
在創建按鈕時,你必須提供兩部分的程式碼:
- 需要執行什麼 ── 在按鈕被使用者點擊或是選取之後,所需要執行的程式碼。
- 按鈕的樣子 ── 描述按鈕外觀的程式碼。
在上述的程式碼中,我已經依照自己的喜好客製化按鈕,我也使用了 State
屬性觀察器 (Property Observer),讓按鈕被點擊的時候改變 showingDetail 變數的數值。
在 SwiftUI,當屬性觀察器的數值被改變,它就會重新載入使用到該變數的視圖。因此,它將會展示出下文會深入解釋的 TrailerView 。
我使用了像是 frame
、foregroundColor
、background
及 cornerRadius
等屬性,來依照我的喜好為按鈕做客製化設計。
設計電影細節畫面
在這個畫面,我使用了 navigationBarHidden(true)
來隱藏導航列,並同時使用了 edgesIgnoringSafeArea(.top)
來忽略螢幕頂端的安全邊界區域。
我使用了 ZStack
在電影細節畫面上方顯示電影的 Image
與 Title
,然後使用了 VStack
在標題的下方顯示了電影的描述。
接著,我將 WatchButton 視圖整合到電影的描述的下方。參考下列程式碼來瞭解 MovieDetail 頁面:
import SwiftUI
struct MovieDetail : View {
var movie: Movie
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ZStack(alignment: .bottom) {
Image(movie.thumbnail)
.resizable()
.aspectRatio(contentMode: .fit)
Rectangle()
.frame(height: 80)
.opacity(0.25)
.blur(radius: 10)
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(movie.title)
.foregroundColor(.white)
.bold()
.font(.largeTitle)
}
.padding(.leading)
.padding(.bottom)
Spacer()
}
}
VStack(alignment: .center,spacing: 15) {
Text(movie.description)
.foregroundColor(.primary)
.font(.body)
.lineSpacing(14)
WatchButton(movie: movie)
.padding()
}.padding(.all)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0,maxHeight: .infinity, alignment: .topLeading)
}
.edgesIgnoringSafeArea(.top)
.navigationBarHidden(true)
}
}
struct MovieDetail_Preview: PreviewProvider {
static var previews: some View {
MovieDetail(movie: moviesData.first!)
}
}
4. 預告片畫面 (Trailer Screen)
在這個章節中,我們會開發預告片的畫面,成果看起來會像這樣:
將 SFSafariViewController 整合到 SwiftUI 之中
我使用 UIViewControllerRepresentable,將 SafariViewController 整合到 SwiftUI。
我們需要加入 makeUIViewController(context:)
方法,以創建一個具有我們所需配置的 SFSafariViewController
。SwiftUI 會在準備好要顯示視圖的時候單次呼叫此方法,並管理視圖控制器的生命週期。
加入一個 updateUIViewController(_:context:)
方法,我們呼叫這方法就可以在關閉按鈕被點擊時,關閉視圖控制器。
struct SafariView: UIViewControllerRepresentable {
let movie: Movie
let safariVC: SFSafariViewController
func makeUIViewController(context: Context) -> SFSafariViewController {
let safariVC = SFSafariViewController(url: URL(string: movie.trailerLink)!)
return safariVC
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
uiViewController.dismissButtonStyle = .close
}
}
struct SafariView_Preview: PreviewProvider {
static var previews: some View {
SafariView(movie: moviesData.first!, safariVC: SFSafariViewController.init(url: URL(string: moviesData.first!.trailerLink)!))
}
}
現在,我們創建了一個名為 TrailerView 的新 SwiftUI 視圖,當 Movie Detail 頁面的 WatchTrailer 按鈕被點擊時,該視圖就會被呼叫。
以預告片連結 URL
呼叫 SafariView 之後,連結將會從 SafariViewController
載入,而預告片將會從 youtube 播放。
看看以預告片的 URL
來呼叫 SFSafariViewController
的程式碼:
import SwiftUI
import SafariServices
struct TrailerView: View {
let movie: Movie
var body: some View {
VStack {
SafariView(movie: movie, safariVC: SFSafariViewController(url: URL(string: movie.trailerLink)!))
}
}
}
struct TrailerView_Preview: PreviewProvider {
static var previews: some View {
TrailerView(movie: moviesData.first!)
}
}
資源
你可以從這個 GitHub 連結中,找到更多詳細的螢幕截圖與專案程式碼,只要你有需要,就可以參考看看。
這個專案已經更新成 Xcode 11+ 及 Swift 5.0 的版本。
結論
我希望你學習到如何使用 SwiftUI 來建立 App,以及如何從中運用不同的 UI 元件,並知道如何將 UIKit 整合到 SwiftUI。
我會考慮再為這個 App 實作更多功能,像是顯示電視節目及更多其他節目和電影的預告片,以嘗試更多 SwiftUI 的概念。
如果你喜歡這個 App,並且希望跟我實作更多功能,請 Follow 本專案的儲存庫。如此一來,只要我對這個電影預告片 App 作出了變更、或是添加了新的模組,你就能夠獲取通知。
你都可以在 Github 上面的 readme 檔案中,找到我開發本次 App 所參考的素材及文章。
希望你覺得這篇文章對你有所幫助。如果你有任何問題,歡迎在下方留言,我會盡量回覆,感謝!
LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang