SwiftUI 教學:運用不同 UI 元件 輕鬆建立一個電影預告片 App


本篇原文(標題:Building Movie Trailer App Using SwiftUI)刊登於作者 Medium,由 Shankar Madeshvaran 所著,並授權翻譯及轉載。

Apple 在 WWDC19 介紹了最新的開發框架,其中之一就是 SwiftUI 以及 Combine。如果你還沒有知道這個消息,簡單來說,SwiftUI 是一種新的方法,讓我們可以藉由宣告方式來創建 UI;而 Combine 是與它一起使用的,Combine 提供了宣告式 Swift API,以處理像是 UI 或是 Network 事件的值。

如果你是剛接觸 SwiftUI,我推薦你可以閱讀這篇文章來瞭解它的基礎概念。

譯者備註:如果你不熟悉 Combine 和 SwiftUI,也可以參考我們之前這篇這篇的教學文章。

在這次的專案之中,我們將會應用 SwiftUI,來建立一個電影預告片 App。

Movie Trailers App using SwiftUI

在建立這個 App 的過程之中,我們可以學習到:

  • 如何從資源檔案載入 JSON 數據
  • 如何使用 ForEachActionSheetImage、以及像是 Rectangle 的形狀
  • 如何使用 UI 元件,像是 ScrollViewTabViewVStackHStackZStackNavigationLink、Button 等
  • 如何使用 Property Observers,像是 StateBinding
  • 如何透過 UIViewRepresentableUIViewControllerRepresentable,使用 SwiftUI 中的 UIKit ViewControllers
  • 如何使用 SFSafariViewControllerUIPageViewController

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 類別,裡面包含了每個電影的詳細資料,像是:idthumbnailtitledescriptiontrailerLinkcatagory 等等。

接著,我創建了一個 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 的儀表板,成果看起來會像這樣:

Dashboard Screen

電影項目的設計

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 對於特定電影項目的預覽:

SwiftUI-Xcode Preview For Single Movie Item

設計橫列 (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"))
    }
}

我使用了 VStackMovieItemsMovie Catagory title 垂直對齊。然後,再使用 ScrollView 將每一個 MovieItem 水平對齊,並形成一個橫列。另外,我再使用 NavigationLink,引導使用者到呈現電影詳細資料的 MovieDetail 頁面。

我也使用了 ForEach 來將 MovieItem 水平對齊,按陣列元素組成一個橫列的電影項目。

SwiftUI-Xcode Preview for Each Row of Movie Items

設計建基於電影類型的 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()
    }
}

我利用了 Groupingfiltering,依照電影類別來創建一個陣列,我們也向它提供 NavigationView,給儀表板設標題,同時也可以在視圖之間進行導航。

我們藉由使用 Catagories ForEachKeys,創建電影類別的橫列。

現在,我們已經為每種電影類別創建了一個 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)
}
SwiftUI-Designing Movie Row Based On Genres

使用 UIKit 來建構介面 — 設計精選電影 (Featured Movie)

SwiftUI 能與所有 Apple 平台現有的 UI 框架完美協作。舉例來說,你可以將 UIKit 的視圖和視圖控制器放到它的視圖裡面,反之亦然。

這部分的教學中,我會向你展示如何將精選電影清單由主選單轉換成 UIPageViewControllerUIPageControl 的包裝實例。

你將會使用 UIPageViewController 來展示 SwiftUI 視圖的輪播,並使用狀態變數與綁定,來協調使用者介面中的數據更新。

1) 創建呈現 UIPageViewController 的視圖

為了在 SwiftUI 中呈現 UIKit 視圖與視圖控制器,我們需要創建類別來遵循 UIViewRepresentableUIViewControllerRepresentable 協定。

根據開發者自定義的類別,創建並配置它們所呈現的 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 裡。
  • UIHostingControllerUIViewController 的一個子類別,代表著一個在 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 協定。

  • UIViewRepresentableUIViewControllerRepresentable 都擁有同樣的生命週期,其方法都會對應到底層的 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()
    }
}
FeaturedMovieList — Xcode Preview
FeaturedMovies List With Page Control

我們加入 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()
    }
}
DashboardView — Xcode Preview

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 都由不同的 ImageText 視圖來客製化。
  • 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)                                 
}
SwiftUI-Navigation From FeaturedMovies List
ForEach(self.movies,id: \.title) { movie in         
NavigationLink(destination: MovieDetail(movie: movie)) {                                             
MovieItem(movie: movie)    
...... 
}                                           
}
Navigation For Movie Item

3. 電影細節畫面 (Movie Detail Screen)

在這個章節中,我們會開發電影細節畫面,成果看起來會像這樣:

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

在創建按鈕時,你必須提供兩部分的程式碼:

  1. 需要執行什麼 ── 在按鈕被使用者點擊或是選取之後,所需要執行的程式碼。
  2. 按鈕的樣子 ── 描述按鈕外觀的程式碼。

在上述的程式碼中,我已經依照自己的喜好客製化按鈕,我也使用了 State 屬性觀察器 (Property Observer),讓按鈕被點擊的時候改變 showingDetail 變數的數值。

在 SwiftUI,當屬性觀察器的數值被改變,它就會重新載入使用到該變數的視圖。因此,它將會展示出下文會深入解釋的 TrailerView

我使用了像是 frameforegroundColorbackgroundcornerRadius 等屬性,來依照我的喜好為按鈕做客製化設計。

Watch Trailer Button — Xcode Preview

設計電影細節畫面

在這個畫面,我使用了 navigationBarHidden(true) 來隱藏導航列,並同時使用了 edgesIgnoringSafeArea(.top) 來忽略螢幕頂端的安全邊界區域。

我使用了 ZStack 在電影細節畫面上方顯示電影的 ImageTitle,然後使用了 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!)
    }
}
MovieDetail — Xcode Preview

4. 預告片畫面 (Trailer Screen)

在這個章節中,我們會開發預告片的畫面,成果看起來會像這樣:

Trailer Screen - SwiftUI

將 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!)
    }
}
TrailerView — Xcode Preview

資源

你可以從這個 GitHub 連結中,找到更多詳細的螢幕截圖與專案程式碼,只要你有需要,就可以參考看看。

這個專案已經更新成 Xcode 11+ 及 Swift 5.0 的版本。

結論

我希望你學習到如何使用 SwiftUI 來建立 App,以及如何從中運用不同的 UI 元件,並知道如何將 UIKit 整合到 SwiftUI。

我會考慮再為這個 App 實作更多功能,像是顯示電視節目及更多其他節目和電影的預告片,以嘗試更多 SwiftUI 的概念。

如果你喜歡這個 App,並且希望跟我實作更多功能,請 Follow 本專案的儲存庫。如此一來,只要我對這個電影預告片 App 作出了變更、或是添加了新的模組,你就能夠獲取通知。

你都可以在 Github 上面的 readme 檔案中,找到我開發本次 App 所參考的素材及文章。

希望你覺得這篇文章對你有所幫助。如果你有任何問題,歡迎在下方留言,我會盡量回覆,感謝!

本篇原文(標題:Building Movie Trailer App Using SwiftUI)刊登於作者 Medium,由 Shankar Madeshvaran 所著,並授權翻譯及轉載。

作者簡介:Shankar Madeshvaran,專注於 iOS 及 Xamarin 的開發者,我喜歡寫關於 iOS 與 Xamarin 概念的文章。歡迎在 GitHub 上關注我。

譯者簡介:HengJay,iOS 初學者,閒暇之餘習慣透過線上 MOOC 資源學習新的技術,喜歡 Swift 平易近人的語法也喜歡狗狗,目前參與生醫領域相關應用的 App 開發,希望分享文章的同時也能持續精進自己的基礎。
LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang


此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This