開發 iOS 的過程中,常常會有彈出 Alert,讓使用者選擇的需求,並且需要知道使用者選擇了哪一個,但卻遇到各種彈出都要一直callback callback 嗎?每次選項都很難掌握,多個選項還要自行客製化,也很難復用,只能一個畫面刻一個?

在本篇教學文章中,我們會了解到幾個要點並實作:

  1. 建立統一入口 Alert 服務化,讓任何地方需要顯示與選擇時都能輕易掌握使用呼叫
  2. 自定義領域設計選項(enum),不管是哪種商業邏輯都能很簡單的讓使用者做選擇,並且輕易掌握使用者選擇後的事件回應
  3. Alert 服務自動讀取 enum 選項並建置 UI Button 列表
  4. 脫離傳統 UIAlertController callback 地獄 / 結合 PromissKit 與 SwiftEntryKit 解偶系統服務與商業邏輯領域設計

本篇文章使用工具、環境與第三方庫:

  1. Xcode 11
  2. macOS 10.15
  3. SwiftUI
  4. PromissKit
  5. SwiftEntryKit
  6. 範例

一樣建議參考範例代碼消化服用呦 🥰

前置工作

首先,先讓我們建立一個展示用的 View,這次我們選擇使用 SwiftUI 來建立!

由於繪製畫面並不是我們這篇文章的重點,有興趣的讀者可以自行研究繪製 view 的代碼。

開啟一個 SwiftUI 的初始專案,修改 ContentView 如下:

//
//  ContentView.swift
//  AlertComponentizationDemo
//
//  Created by yasuoyuhao on 2019/9/13.
//  Copyright © 2019 yasuoyuhao. All rights reserved.
//

import SwiftUI

struct ContentView: View {
    
    @State var show = false
    @State var viewState = CGSize.zero
    
    var body: some View {
        ZStack {
            
            TitleView()
                .blur(radius: show ? 20 : 0)
                .animation(.default)
            
            CardBottomView()
                .blur(radius: show ? 20 : 0)
                .animation(.default)
            
            CardView()
                .background(Color("background9"))
                .cornerRadius(10)
                .shadow(radius: 20)
                .offset(x: 0, y: -40)
                .scaleEffect(0.85)
                .rotationEffect(Angle(degrees: show ? 15 : 0))
                //                .rotation3DEffect(Angle(degrees: show ? 50 : 0), axis: (x: 10.0, y: 10.0, z: 10.0))
                .blendMode(.hardLight)
                .animation(.easeInOut(duration: 0.7))
                .offset(x: viewState.width, y: viewState.height)
            
            CardView()
                .background(Color("background8"))
                .cornerRadius(10)
                .shadow(radius: 20)
                .offset(x: 0, y: -20)
                .scaleEffect(0.9)
                .rotationEffect(Angle(degrees: show ? 10 : 0))
                //                .rotation3DEffect(Angle(degrees: show ? 40 : 0), axis: (x: 10.0, y: 10.0, z: 10.0))
                .blendMode(.hardLight)
                .animation(.easeInOut(duration: 0.5))
                .offset(x: viewState.width, y: viewState.height)
            
            
            CertificateView()
                .offset(x: viewState.width, y: viewState.height)
                .scaleEffect(0.95)
                .rotationEffect(Angle(degrees: show ? 5 : 0))
                //                .rotation3DEffect(Angle(degrees: show ? 30 : 0), axis: (x: 10.0, y: 10.0, z: 10.0))
                .animation(.spring())
                .onTapGesture {
                      	// 點擊事件
            }
            .gesture(
                DragGesture()
                    .onChanged { value in
                        self.viewState = value.translation
                        self.show = true
                }
                .onEnded { value in
                    self.viewState = CGSize.zero
                    self.show = false
                }
            )
        }
    }
}

struct CertificateView: View {
    var body: some View {
        return VStack {
            
            HStack {
                VStack(alignment: .leading) {
                    Text("yasuoyuhao")
                        .font(.headline)
                        .fontWeight(.bold)
                        .foregroundColor(Color("accent"))
                        .padding(.top)
                    Text("Alert Demo")
                        .foregroundColor(.white)
                }
                Spacer()
            }
            .padding(.horizontal)
            
            Spacer()
            
            Image("Background")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: 340.0, height: 150, alignment: .center)
                .clipped()
        }
        .frame(width: 340.0, height: 220)
        .background(Color.black)
        .cornerRadius(10)
        .shadow(radius: 20)
    }
}


struct CardView: View {
    var body: some View {
        return VStack {
            Text("Card Back")
        }
        .frame(width: 300, height: 220)
        .cornerRadius(10)
        .shadow(radius: 20)
        .offset(x: 0, y: -20)
        
    }
}

struct TitleView : View {
    var body: some View {
        return VStack {
            HStack {
                Text("Alert Demo")
                    .font(.largeTitle)
                    .fontWeight(.heavy)
                Spacer()
            }
            Image("Illustration5")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: 210.0, height: 210.0, alignment: .center)
                .onTapGesture {
                    // 點擊事件
            }
            
            Spacer()
        }
        .padding()
    }
}

struct CardBottomView : View {
    var body: some View {
        return VStack(spacing: 20.0) {
            Rectangle()
                .frame(width: 60, height: 6)
                .cornerRadius(3.0)
                .opacity(0.1)
            Text("請點擊卡片、數據圖查看提示效果")
                .lineLimit(10)
            Spacer()
        }
        .frame(minWidth: 0, maxWidth: .infinity)
        .padding()
        .padding(.horizontal)
        .background(Color.white)
        .cornerRadius(30)
        .shadow(radius: 20)
        .offset(y: 600)
    }
}

#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

其中有需要的圖片資源都在範例代碼中,請記得取呦!

現在我們應該可以看到以下畫面了!

嗯!非常不錯,這次我們稍微設計了一下展示畫面與動畫,讓整體而言不要那麼枯燥(?)!

設計思路

寫代碼前我們通常需要設計一番,並思考哪些功能需要,要應用什麼樣的設計模式。

首先,我們先來分析需求!

需求1:我們想要有一個不耦合於任何組件的 Alert

需求2 :我們想要有一個服務專門提供接口,上層只要提供選項與數據,由這一層服務幫我們完成繪製畫面、並且回傳使用者選擇的選項

需求 3:這個選項應該要可以隨意擴充,以及應對不同商業邏輯的選項組合

需求4:我們想要回傳選項時,可以讓流程很好的執行,不會造成 callback hell

為此,我們集成了兩個很常用的第三方組件 PromiseKitSwiftEntryKit 來解決我們的需求。

實作服務層

首先,我們先建立一個 class 做為我們的服務層入口 MessageService

並且我們使用單例模式來處理 需求1

class MessageService {
    
    static let shared = MessageService()
    
    private init() { }
}

接下來我們實作 SwiftEntryKit 的容器

fileprivate let attributesPopUp: EKAttributes = {
        var attributes = EKAttributes.centerFloat
        attributes.windowLevel = .alerts
        attributes.hapticFeedbackType = .success
        attributes.screenInteraction = .absorbTouches
        attributes.entryInteraction = .absorbTouches
        attributes.scroll = .disabled
        attributes.screenBackground = .color(color: EKColor.white.with(alpha: 0.5))
        attributes.entryBackground = .color(color: EKColor.white.with(alpha: 0.98))
        attributes.entranceAnimation = .init(scale: .init(from: 0.9,
                                                          to: 1,
                                                          duration: 0.4,
                                                          spring: .init(damping: 0.8,
                                                                        initialVelocity: 0)),
                                             fade: .init(from: 0,
                                                         to: 1,
                                                         duration: 0.3))
        attributes.exitAnimation = .init(scale: .init(from: 1,
                                                      to: 0.4,
                                                      duration: 0.4,
                                                      spring: .init(damping: 1,
                                                                    initialVelocity: 0)),
                                         fade: .init(from: 1,
                                                     to: 0,
                                                     duration: 0.2))
        attributes.displayDuration = .infinity
        attributes.positionConstraints.maxSize = .init(width: .constant(value: UIScreen.main.bounds.maxX), height: .fill)
        return attributes
    }()

EKAttributesSwiftEntryKit 設計中的一塊組件,他是整個 Alert 的容器,裡面可以放置其他 SwiftEntryKit 設計好的 view 或自定義 view,建議搭配官方文檔做參考。

接下來,我們建置一個 function 用來呼叫我們理想中的 Alert

func showTableSelectionView<T: RawCaseable>(title: String, description: String, data: T.Type, image: UIImage? = nil, imageSize: CGSize = CGSize(width: 80, height: 80), isHaveCancel: Bool = true) -> Promise<T> where T.RawValue == String {

}

showTableSelectionView 是一個泛型方法,範圍於 RawCaseable

RawCaseable 是我們自己定義的一個 protocol 用於生成我們的選項列舉

protocol RawCaseable: RawRepresentable, CaseIterable { }

對於 RawRepresentableCaseIterable 的協定內容,可以參考官方文檔。

這是為了方便於讓我們的 enum 可以使用 foreach 處理不特定數量與不特定標題的方式。

其中參數 title 代表這個 Alert 的顯示標題文字

description 代表描述文字

data 代表列舉選項的目標型別

image 用於 Alert 的圖示

imageSize 用於設定 image 的大小

isHaveCancel 代表使否要顯示取消的選項,預設是 true

並且會返回一個 Promise,可以接續選擇後的處理流程。

接下來,讓我們進入重點!

先建立一個 Promise

return Promise<T>.init(resolver: { (resolver) in

})

然後,我們開始根據傳入值繪製 Alert

return Promise<T>.init(resolver: { (resolver) in
            let title = EKProperty.LabelContent(text: title, style: .init(font: .systemFont(ofSize: 16), color: .black, alignment: .center))
            let description = EKProperty.LabelContent(text: description, style: .init(font: PopUpMessageFont.shared.subTitleFont, color: EKColor.black.with(alpha: 0.98), alignment: .center))
            
            let buttonFont: UIFont = .systemFont(ofSize: 16)
            let buttonColor: EKColor = EKColor.init(red: 0, green: 66, blue: 188)
            
            let image = EKProperty.ImageContent.init(image: image ?? #imageLiteral(resourceName: "icons8-swift"), size: CGSize(width: imageSize.width, height: imageSize.height), contentMode: .scaleAspectFit, makesRound: true)
            let simpleMessage = EKSimpleMessage(image: image, title: title, description: description)
            
            var buttonsBarContent = EKProperty.ButtonBarContent(with: [], separatorColor: buttonColor.with(alpha: 0.2), expandAnimatedly: true)
            
            // Close button
            if isHaveCancel {
                
                let closeButtonLabelStyle = EKProperty.LabelStyle(font: buttonFont, color: EKColor.black.with(alpha: 0.8))
                let closeButtonLabel = EKProperty.LabelContent(text: "取消", style: closeButtonLabelStyle)
                let closeButton = EKProperty.ButtonContent(label: closeButtonLabel, backgroundColor: .clear, highlightedBackgroundColor: EKColor.white.with(alpha: 0.05)) {
                    
                    resolver.reject(UIError.userDoIsCancal)
                    
                    SwiftEntryKit.dismiss()
                }
                
                buttonsBarContent.content.append(closeButton)
            }
})

根據官方文檔,我們將 title, description, image, isHaveCancel 的參數,繪製成符合 App 中樣式的組件,並且放入 simpleMessage 中。

可以看到取消中的執行方法為

resolver.reject(UIError.userDoIsCancal)
SwiftEntryKit.dismiss()

代表我們 resolver.rejectPromise,並且關閉了 Alert

接著,進入重頭戲,繪製我們的商業邏輯領域選項!

T.allCases.forEach { (item) in
                let controlButtonLabelStyle = EKProperty.LabelStyle(font: buttonFont, color: buttonColor)
                let controlButtonLabel = EKProperty.LabelContent(text: NSLocalizedString(item.rawValue, comment: ""), style: controlButtonLabelStyle)
                let controlButton = EKProperty.ButtonContent(label: controlButtonLabel, backgroundColor: .clear, highlightedBackgroundColor: buttonColor.with(alpha: 0.05)) {
                    SwiftEntryKit.dismiss()
                    
                    resolver.fulfill(item)
                    return
                }
                
                buttonsBarContent.content.append(controlButton)
            }

這邊我們利用了剛剛設置好的協定,實現繪製選項與選項標題,並且於返回值 resolver.fulfill 使用者選的選項,加入到了 buttonsBarContent.content 列表,並且關閉了 Alert

最後,讓我們 display 我們繪製好的 Alert

let alertMessage = EKAlertMessage(simpleMessage: simpleMessage, buttonBarContent: buttonsBarContent)
            
            // Setup the view itself
            let contentView = EKAlertMessageView(with: alertMessage)
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                SwiftEntryKit.display(entry: contentView, using: self.attributesPopUp)
            }

大功告成!我們完成了統一,可復用的 Alert

整個 function 如下

func showTableSelectionView<T: RawCaseable>(title: String, description: String, data: T.Type, image: UIImage? = nil, imageSize: CGSize = CGSize(width: 80, height: 80), isHaveCancel: Bool = true) -> Promise<T> where T.RawValue == String {
        
        return Promise<T>.init(resolver: { (resolver) in
            let title = EKProperty.LabelContent(text: title, style: .init(font: .systemFont(ofSize: 16), color: .black, alignment: .center))
            let description = EKProperty.LabelContent(text: description, style: .init(font: PopUpMessageFont.shared.subTitleFont, color: EKColor.black.with(alpha: 0.98), alignment: .center))
            
            let buttonFont: UIFont = .systemFont(ofSize: 16)
            let buttonColor: EKColor = EKColor.init(red: 0, green: 66, blue: 188)
            
            let image = EKProperty.ImageContent.init(image: image ?? #imageLiteral(resourceName: "icons8-swift"), size: CGSize(width: imageSize.width, height: imageSize.height), contentMode: .scaleAspectFit, makesRound: true)
            let simpleMessage = EKSimpleMessage(image: image, title: title, description: description)
            
            var buttonsBarContent = EKProperty.ButtonBarContent(with: [], separatorColor: buttonColor.with(alpha: 0.2), expandAnimatedly: true)
            
            // Close button
            if isHaveCancel {
                
                let closeButtonLabelStyle = EKProperty.LabelStyle(font: buttonFont, color: EKColor.black.with(alpha: 0.8))
                let closeButtonLabel = EKProperty.LabelContent(text: "取消", style: closeButtonLabelStyle)
                let closeButton = EKProperty.ButtonContent(label: closeButtonLabel, backgroundColor: .clear, highlightedBackgroundColor: EKColor.white.with(alpha: 0.05)) {
                    
                    resolver.reject(UIError.userDoIsCancal)
                    
                    SwiftEntryKit.dismiss()
                }
                
                buttonsBarContent.content.append(closeButton)
            }
            
            T.allCases.forEach { (item) in
                let controlButtonLabelStyle = EKProperty.LabelStyle(font: buttonFont, color: buttonColor)
                let controlButtonLabel = EKProperty.LabelContent(text: NSLocalizedString(item.rawValue, comment: ""), style: controlButtonLabelStyle)
                let controlButton = EKProperty.ButtonContent(label: controlButtonLabel, backgroundColor: .clear, highlightedBackgroundColor: buttonColor.with(alpha: 0.05)) {
                    SwiftEntryKit.dismiss()
                    
                    resolver.fulfill(item)
                    return
                }
                
                buttonsBarContent.content.append(controlButton)
            }
            
            let alertMessage = EKAlertMessage(simpleMessage: simpleMessage, buttonBarContent: buttonsBarContent)
            
            // Setup the view itself
            let contentView = EKAlertMessageView(with: alertMessage)
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                SwiftEntryKit.display(entry: contentView, using: self.attributesPopUp)
            }
            
        })
    }

使用接口

讓我們回到剛剛繪製好的 view 來執行 Alert 吧!

我們建立一個 enum FoodKind 模擬使用者點餐的選項

enum FoodKind: String, RawCaseable {
    case steak = "牛排"
    case chickenChops = "雞排"
    case italianNoodles = "義大利麵"
    case hainanChickenRice = "海南雞飯"
}

找到 CertificateViewonTapGesture 並且寫入:

_ = MessageService.shared.showTableSelectionView(title: "餐點", description: "請選擇你的餐點", data: FoodKind.self).done { (kind) in
                        
                        print("你選擇了: \(kind.rawValue)")
                        
                        // to something for your seletion
                        switch kind {
                            
                        case .steak:
                            ()
                        case .chickenChops:
                            ()
                        case .italianNoodles:
                            ()
                        case .hainanChickenRice:
                            ()
                        }
                    }.catch({ (error) in
                        if let error = error as? UIError {
                            print(error.localizedDescription)
                        }
                    })

此時,執行並點擊卡片會出現畫面。

點擊牛排或取消,我們發現我們可以知道使用者選取了什麼!

讓我們再感受一下他的強大!我們建立遊戲職業種類的 enum HeroKind

enum HeroKind: String, RawCaseable {
    case fighter = "鬥士"
    case assassin = "刺客"
    case mage = "法師"
    case shooter = "射手"
    case support = "輔助"
}

找到 TitleView 中的 Image onTapGesture 寫入:

_ = MessageService.shared.showTableSelectionView(title: "英雄", description: "請選擇您的英雄職位", data: HeroKind.self).done { (hero) in
                        print("你選擇了: \(hero.rawValue)")
                        
                        switch hero {
                            
                        case .fighter:
                            ()
                        case .assassin:
                            ()
                        case .mage:
                            ()
                        case .shooter:
                            ()
                        case .support:
                            ()
                            
                        }
                    }.catch({ (error) in
                        if let error = error as? UIError {
                            print(error.localizedDescription)
                        }
                    })

點一下圖,我們可以看到以下畫面:

恭喜!至此我們解決了我們的需求,建立統一樣式、復用、自由擴充選項的 Alert

總結

本文所闡述的設計理念並不困難,筆者覺得實現代碼時先描述需求與設計思路是更重要的,有了明確的需求與設計思路,我們可以很好的解決問題,或許設計中有時是沒想到的問題,我們可以透過反覆迭代與設計去解決複雜問題。希望這篇文章有帶給您幫助,感謝您的閱讀,祝你有個美好的 Coding 夜晚,下次見。

yasuoyuhao,自認為終身學習者,對多領域都有濃厚興趣,喜歡探討各種事物。目前專職軟體開發,系統架構設計,企業解決方案。最喜歡 iOS with Swift。