Upgrade to Pro — share decks privately, control downloads, hide ads and more …

第一次_SwiftUI_10YearChallenge_App_親密接觸.pdf

 第一次_SwiftUI_10YearChallenge_App_親密接觸.pdf

如果有十年的程式練習,我就能開發比 IG 還棒的 App。十年的練習有可能嗎? 可以 ! 就從 iPlayground 的 3 個小時,開始第一次的 SwiftUI App 親密接觸。

Apple 最新推出的 SwiftUI 幫助我們以更直覺精簡的程式製作 App (Better apps. Less code),活動將搭配滿滿的實作練習,介紹 SwiftUI 的各種技術。比方畫面的製作,常用 UI 元件的使用,各種有趣的圖片效果,data binding ,結合表格,Form,navigation bar & tab bar 製作多頁面 App,帶著大家一步步創作有趣的 10YearChallenge App。

More Decks by 愛瘋一切為蘋果的彼得潘

Other Decks in Programming

Transcript

  1. 第⼀次 SwiftUI 10YearChallenge App 親密接觸 https://medium.com/@z1235678 玲嘉 Sharon https://medium.com/@sharon830316 保鈴

    https://medium.com/@duc50609 Allen https://medium.com/@allenchen08040614 奇妙仙⼦ https://medium.com/@le821227 第⼀次 SwiftUI 10YearChallenge App 親密接觸 https://medium.com/@apppeterpan Peter Pan & 可愛的 SwiftUI App 助教
  2. SwiftUI 的好處 • Better apps. Less code: 程式愈少,bug 愈少 •

    Declarative Syntax : 更容易理解,⽤程式描述畫⾯長什麼樣⼦ • Preview: 可以預覽程式產⽣的 App 畫⾯,甚⾄將預覽畫⾯變成可以操作互動的 App。 • 以程式⽣成畫⾯,不像 storyboard 容易有多⼈合作的版本衝突問題。 • iOS,macOS,watchOS, tvOS App 可以採⽤類似的寫法。 • 不會像從前 storyboard & 程式分開,常有不⼀致的問題,比⽅ outlet / action 問 題,cell 名字問題 • 利⽤ binding 機制,資料跟畫⾯更容易同步 • 可以跟 UIKit 結合
  3. SwiftUI 的限制 • iOS 13 以上才能使⽤ • 只能⽤ Swift 撰寫

    SwiftUI 程式,不能⽤ Objective-C • 預覽 SwiftUI 設計的畫⾯要搭配 macOS 10.15 以 上版本,但 macOS 10.14 還是可以開發
  4. 練習 建立 Single View SwiftUI 專案和 觀看預覽畫⾯ ps:不是 macOS 10.15,請⼿動輸入程式,

    將 App 安裝到模擬器看畫⾯ macOS 10.14 Mojave 開發 SwiftUI App 的缺點 http://bit.ly/2m1weQ1
  5. 定義畫⾯的型別 struct ContentView: View { var body: some View {

    Text("Hello World") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } 定義預覽畫⾯的型別,遵從 protocol PreviewProvider, 在此我們⽣成型別 ContentView 的元件當預覽畫⾯ SwiftUI 的 UI 元件以 struct 定義, 遵從 protocol View
  6. SwiftUI 的 UI 元件以 struct 定義, 遵從 protocol View struct

    Button<Label> : View where Label : View struct Text : Equatable extension Text : View struct Image : Equatable extension Image : View SwiftUI 的 UI 元件都是 View
  7. protocol View public protocol View { /// The type of

    view representing the body of this view. /// /// When you create a custom view, Swift infers this type from your /// implementation of the required `body` property. associatedtype Body : View /// Declares the content and behavior of this view. var body: Self.Body { get } } 遵從 protocol View 須定義 computed property body • computed property http://bit.ly/2lVJeqh • 幫助 protocol 實現型別代號的 Associated Type http://bit.ly/2lDJIS6
  8. 變數 body 的內容決定畫⾯顯⽰ 的東⻄ var body: some View { return

    Text("Hello World") } var body: some View { Text("Hello World") } function & computed property 只有⼀⾏時可省略 return http://bit.ly/2FuvcTA 只有⼀⾏程式時可以省略 return 沒有省略 return • 變數 body 是 computed property,所以有 { } • 讀取變數 body 時,將執⾏ { } 的程式,得到它回傳的東⻄ • 回傳 Text,所以畫⾯顯⽰⽂字
  9. var body: some View struct ContentView: View { var body:

    some View { Text("Hello World") } } • 回傳的元件的型別須遵從 protocol View,必須是 某⼀種 view,畫⾯才能顯⽰,比⽅⽂字,圖片等 • some 跟 Swift 的 Opaque Types 有關, https://docs.swift.org/swift-book/LanguageGuide/OpaqueTypes.html 某⼀種 view
  10. 加入元件,ViewBuilder, Group,ForEach & KeyPath 畫⾯加入元件的⽅法 http://bit.ly/2lWiy8Y ViewBuilder & Ambiguous reference

    to member buildBlock() http://bit.ly/2lZTTR1 SwiftUI 的 ForEach (待會再說明) http://bit.ly/2kH7lsK 在結尾輸入 { } ⽣成 SwiftUI 元件 http://bit.ly/2lWVaIn
  11. iOS 13 的 SF Symbols • 超過 1500 個 •

    https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ • https://developer.apple.com/design/ • https://sfsymbols.com • 沒有美術天份也能開發美美的 App 了 ! • 可以另外⾃⼰設計 • 圖片可調整樣式和搭配⽂字字型
  12. 控制 iOS App 的第⼀個畫⾯ http://bit.ly/2YWmU2J func scene(_ scene: UIScene, willConnectTo

    session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { let contentView = EditPhotoView() if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: contentView) self.window = window window.makeKeyAndVisible() } } SceneDelegate.swift
  13. SceneDelegate & UISceneDelegate • iOS 13 開始,App 執⾏後可有多個 scene,每個 scene

    可以有多個 window • scene 有 delegate,型別是 UISceneDelegate • scene 進入前景 / 背景時將觸發 delegate 的相關 method • 當 App scene (window) 即將產⽣時將呼叫 scene delegate 的 scene(_:willConnectTo:options:) https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle
  14. 加入 GeometryReader & Image struct EditPhotoView: View { var body:

    some View { GeometryReader { geometry in Image("peter2019") .resizable() .scaledToFill() .frame(width: geometry.size.width, height: geometry.size.width / 4 * 3) .clipped() } } } 讓圖片寬度等於螢幕寬度,比例 4:3 SwiftUI 讓圖片等於螢幕寬度 & 設定比例的⽅法 http://bit.ly/2m0RjKw
  15. 加入 VStack 容納圖片和表單 GeometryReader { geometry in VStack { Image("peter2019")

    .resizable() .scaledToFill() .frame(width: geometry.size.width, height: geometry.size.width / 4 * 3) .clipped() } }
  16. 利⽤ brightness 調整圖片亮度 GeometryReader { geometry in VStack { Image("peter2019")

    .resizable() .scaledToFill() .frame(width: geometry.size.width, height: geometry.size.width / 4 * 3) .clipped() .brightness(self.brightnessAmount) } }
  17. 因為 @escaping, 因此 closure 裡讀取 property 要加 self GeometryReader 的

    init init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) http://bit.ly/2KfttUX
  18. 加入 Form(表單) & Slider VStack { Image("peter2019") .resizable() .scaledToFill() .frame(width:

    geometry.size.width, height: geometry.size.width / 4 * 3) .clipped() .brightness(self.brightnessAmount) Form { HStack { Text("亮度") Slider(value: self.$brightnessAmount, in: 0...1, minimumValueLabel: Image(systemName: "sun.max.fill").imageScale(.small), maximumValueLabel: Image(systemName: "sun.max.fill").imageScale(.large)) { Text("") } } } }
  19. Declarative(陳述) Syntax 更容易理解,⽤程式描述畫⾯長什麼樣⼦ VStack { Image("peter2019") .resizable() .scaledToFill() .frame(width: geometry.size.width,

    height: geometry.size.width / 4 * 3) .clipped() .brightness(self.brightnessAmount) Form { HStack { Text("亮度") Slider(value: self.$brightnessAmount, in: 0...1, minimumValueLabel: Image(systemName: "sun.max.fill").imageScale(.small), maximumValueLabel: Image(systemName: "sun.max.fill").imageScale(.large)) { Text("") } } } }
  20. 加入時間的相關 property struct EditPhotoView: View { @State private var brightnessAmount:

    Double = 0 @State private var selectDate = Date() let today = Date() let startDate = Calendar.current.date(byAdding: .year, value: -2, to: Date())! var year: Int { return Calendar.current.component(.year, from: selectDate) } selectDate: 選擇的⽇期 today: 今天⽇期 startDate: 開始⽇期 year: 選擇⽇期的年份
  21. 定義儲存照片資料的 array photoInfos 新增 Data.swift let photoInfos = [PhotoInfo(year: 2017,

    name: "純真"), PhotoInfo(year: 2018, name: "可愛"), PhotoInfo(year: 2019, name: "帥氣")] ⽅便測試的全域變數
  22. 新增表格的 row view struct PhotoRow: View { var photoInfo: PhotoInfo

    var body: some View { Text("Hello World!") } } struct PhotoRow_Previews: PreviewProvider { static var previews: some View { PhotoRow(photoInfo: photoInfos[0]) } } 顯⽰第⼀個照片資料
  23. 設計 PhotoRow 的畫⾯ var body: some View { HStack {

    Image("peter\(photoInfo.year)") .resizable() .scaledToFill() .frame(width: 50, height: 50) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 4)) .shadow(radius: 10) Text("\(photoInfo.year)") } } 讓東⻄變圓形的各種⽅法 http://bit.ly/2korkMz
  24. 利⽤ Spacer 讓 HStack 的寬度變 成螢幕寬度 HStack { Image("peter\(photoInfo.year)") .resizable()

    .scaledToFill() .frame(width: 50, height: 50) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 4)) .shadow(radius: 10) Text("\(photoInfo.year)") Spacer() }
  25. 利⽤ previewLayout 調整 preview 的尺⼨ struct PhotoRow_Previews: PreviewProvider { static

    var previews: some View { PhotoRow(photoInfo: photoInfos[0]) .previewLayout(.fixed(width: 300, height: 70)) } }
  26. 練習 新增 PhotoRow 的畫⾯ 如果想在模擬器測試, 可修改 SceneDelegate.swift, 將 contentView 改成

    PhotoRow let contentView = PhotoRow(photoInfo: photoInfos[0]) 資料夾 4_PhotoRow
  27. List(表格) 的 init init<Data, RowContent>(_ data: Data, @ViewBuilder rowContent: @escaping

    (Data.Element) -> RowContent) where Content == ForEach<Data, Data.Element.ID, HStack<RowContent>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable init<Data, ID, RowContent>(_ data: Data, id: KeyPath<Data.Element, ID>, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Content == ForEach<Data, ID, HStack<RowContent>>, Data : RandomAccessCollection, ID : Hashable, RowContent : View 類似 ForEach,data 參數可傳入 array,然後讓成員型別 遵從 Identifiable 或⽤ KeyPath 指定 id 區分成員
  28. 讓 PhotoInfo 遵從 protocol Identifiable struct PhotoInfo: Identifiable { var

    id = UUID() var year: Int var name: String } • 讓 PhotoInfo 遵從 protocol Identifiable • 遵從 protocol Identifiable 必須定義 property id 產⽣獨⼀無⼆的 ID http://bit.ly/2lZcJYl
  29. 新增照片列表畫⾯ struct PhotoList: View { var body: some View {

    List(photoInfos) { (photoInfo) in PhotoRow(photoInfo: photoInfo) } } } 新增 PhotoList.swift
  30. 新增照片明細⾴ struct PhotoDetail: View { var photoInfo: PhotoInfo var body:

    some View { Image("peter\(photoInfo.year)") .resizable() .scaledToFit() } } struct PhotoDetail_Previews: PreviewProvider { static var previews: some View { PhotoDetail(photoInfo: photoInfos[0]) } } 新增 PhotoDetail.swift
  31. HStack { List(photoInfos) { (photoInfo) in PhotoRow(photoInfo: photoInfo) } }

    加入 NavigationView PhotoList.swift 按住 cmd 鍵點選 List,從選單選擇 Embed in HStack 1
  32. 加入 NavigationView var body: some View { NavigationView { List(photoInfos)

    { (photoInfo) in PhotoRow(photoInfo: photoInfo) } } } 將 HStack 改成 NavigationView 2
  33. 照片明細⾴顯⽰標題 struct PhotoDetail: View { var photoInfo: PhotoInfo var body:

    some View { Image("peter\(photoInfo.year)") .resizable() .scaledToFit() .navigationBarTitle(photoInfo.name) } }
  34. ⽤ TabView 實現兩個 tab ⾴⾯ struct TenYearTab: View { var

    body: some View { TabView { EditPhotoView().tabItem { VStack { Image(systemName: "photo") Text("Edit") } } PhotoList().tabItem { VStack { Image(systemName: "photo.on.rectangle") Text("Photos") } } } } } 新增 TenYearTab.swift 利⽤ tabItem(_:) 設定 tab 的圖⽂ tabItem(_:) 只能顯⽰傳統 tab 的樣式,比⽅圖片在上,⽂字在下
  35. tabItem(_:) 可省略 VStack TabView { EditPhotoView().tabItem { Image(systemName: "photo") Text("Edit")

    } PhotoList().tabItem { Image(systemName: "photo.on.rectangle") Text("Photos") } } func tabItem<V>(@ViewBuilder _ label: () -> V) -> some View where V : View 當 tabItem { } 裡⽣成 image & text 時, 將⾃動以圖片在上,⽂字在下的⽅式呈現
  36. 輸入 view 的型別名 Form { BrightnessSlider() DatePicker("時間", selection: self. $selectDate,

    in: self.startDate...self.today, displayedComponents: .date) } 輸入 BrightnessSlider
  37. 在 BrightnessSlider 加上 @State property brightnessAmount 可以嗎 ? struct BrightnessSlider:

    View { @State var brightnessAmount: Double var body: some View { HStack { Text("亮度") Slider(value: self.$brightnessAmount, in: 0...1, minimumValueLabel: Image(systemName: "sun.max.fill").imageScale(.small), maximumValueLabel: Image(systemName: "sun.max.fill").imageScale(.large)) { Text("") } } } }
  38. 建立 BrightnessSlider 時傳入 brightnessAmount Form { BrightnessSlider(brightnessAmount: self.brightnessAmount) DatePicker("時間", selection:

    self.$selectDate, in: self.startDate...self.today, displayedComponents: .date) } EditPhotoView.swift
  39. 利⽤ @Binding 綁定 EditPhotoView 的 brightnessAmount @Binding 將讓 BrightnessSlider 的

    brightnessAmount 參考 EditPhotoView 的 brightnessAmount,修改時更新 EditPhotoView 的 brightnessAmount struct BrightnessSlider: View { @Binding var brightnessAmount: Double var body: some View { HStack { Text("亮度") Slider(value: self.$brightnessAmount, in: 0...1, minimumValueLabel: Image(systemName: "sun.max.fill").imageScale(.small), maximumValueLabel: Image(systemName: "sun.max.fill").imageScale(.large)) { Text("") } } } }
  40. ⽣成 BrightnessSlider 時傳入 $brightnessAmount Form { BrightnessSlider(brightnessAmount: self. $brightnessAmount) DatePicker("時間",

    selection: self.$selectDate, in: self.startDate...self.today, displayedComponents: .date) }
  41. source of truth 單⼀資訊來源 @State var brightnessAmount: Double @Binding var

    brightnessAmount1: Double @Binding var brightnessAmount2: Double ⽤ @State 宣告的 property 將是資料來源, 其它以 @Binding 宣告的 property 都是參考它, 存取同⼀份資料,避免資料不同步的問題 例⼦:
  42. 其它東⻄也可以 extract subview,比⽅上⽅的 Image struct TenYearImage: View { let width:

    CGFloat let selectDate: Date let brightnessAmount: Double var year: Int { return Calendar.current.component(.year, from: selectDate) } var body: some View { Image("peter\(self.year)") .resizable() .scaledToFill() .frame(width: width, height: width / 4 * 3) .clipped() .brightness(self.brightnessAmount) } } 不需要加 @Binding,因為不會有修改更新的問題
  43. 其它東⻄也可以 extract subview,比⽅上⽅的 Image struct EditPhotoView: View { @State private

    var brightnessAmount: Double = 0 @State private var selectDate = Date() let today = Date() let startDate = Calendar.current.date(byAdding: .year, value: -2, to: Date())! var body: some View { GeometryReader { geometry in VStack { TenYearImage(width: geometry.size.width, selectDate: self.selectDate, brightnessAmount: self.brightnessAmount) Form { BrightnessSlider(brightnessAmount: self.$brightnessAmount) DatePicker("時間", selection: self.$selectDate, in: self.startDate...self.today, displayedComponents: .date) } } } } }
  44. 加入 blend 的相關 property struct EditPhotoView: View { @State private

    var brightnessAmount: Double = 0 @State private var selectDate = Date() @State private var selectBlend = BlendMode.screen let today = Date() let startDate = Calendar.current.date(byAdding: .year, value: -2, to: Date())! let blendModes: [BlendMode] = [.screen, .colorDodge, .colorBurn] Picker 利⽤ binding 綁定 selectBlend,BlendMode 是 enum,所以遵從 protocol Hashable,可以被 Picker 綁定
  45. ⽤ ZStack 包含 2 個要混合的 Image VStack { ZStack {

    Image("texture") .resizable() .scaledToFill() .frame(width: geometry.size.width, height: geometry.size.width / 4 * 3) .clipped() TenYearImage(width: geometry.size.width, selectDate: self.selectDate, brightnessAmount: self.brightnessAmount) .blendMode(self.selectBlend) }
  46. 將 modifier 作⽤於 ZStack ZStack { Image("texture") .resizable() TenYearImage(selectDate: self.selectDate,

    brightnessAmount: self.brightnessAmount) .blendMode(self.selectBlend) } .scaledToFill() .frame(width: geometry.size.width, height: geometry.size.width / 4 * 3) .clipped()
  47. 將 modifier 作⽤於 ZStack struct TenYearImage: View { let selectDate:

    Date let brightnessAmount: Double var year: Int { return Calendar.current.component(.year, from: selectDate) } var body: some View { Image("peter\(self.year)") .resizable() .brightness(self.brightnessAmount) } }
  48. 在 Form 裡加入 Picker 選擇 blend • ForEach 要求當 id

    的資料型別遵從 protocol Hashable, enum 定義的型別預設即遵從 Hashable protocol。(ps: 不過搭配 associated values 的 enum 是例外) • ViewBuilder 裡有些寫法會有問題。 http://bit.ly/2Y4isyc
  49. 在 Form 裡加入 Picker 選擇 blend Picker("選擇 blend", selection: self.$selectBlend)

    { ForEach(self.blendModes, id: \.self) { (blendMode) in Text(blendMode.name) } }
  50. 解法1: 搭配 WheelPickerStyle Picker("選擇 blend", selection: self.$selectBlend) { ForEach(self.blendModes, id:

    \.self) { (blendMode) in Text(blendMode.name) } } .pickerStyle(WheelPickerStyle())
  51. 解法2: 搭配 SegmentedPickerStyle Picker("選擇 blend", selection: self.$selectBlend) { ForEach(self.blendModes, id:

    \.self) { (blendMode) in Text(blendMode.name) } } .pickerStyle(SegmentedPickerStyle())
  52. 1. 從 VStack 選擇 Embed in HStack 2. 將 HStack

    改成 NavigationView 解法3: 搭配 Navigation View 在下⼀⾴設定 EditPhotoView.swift GeometryReader { geometry in NavigationView { VStack {
  53. 利⽤ onTapGesture 縮放圖片 struct PhotoDetail: View { var photoInfo: PhotoInfo

    @State private var scale: CGFloat = 1 var body: some View { Image("peter\(photoInfo.year)") .resizable() .scaledToFit() .navigationBarTitle(photoInfo.name) .scaleEffect(scale) .onTapGesture(count: 2) { self.scale = self.scale == 1 ? 2 : 1 } } } scaleEffect(_:anchor:): 將 view 放⼤ / 縮⼩ onTapGesture(count:perform:): 設定點擊⼿勢觸發的程式 加 @State 才可以改變
  54. preview 多個 device struct EditPhotoView_Previews: PreviewProvider { static var previews:

    some View { Group { EditPhotoView() .previewDevice(PreviewDevice(rawValue: "iPhone 11")) .previewDisplayName("iPhone 11") EditPhotoView() .previewDevice(PreviewDevice(rawValue: "iPhone SE")) .previewDisplayName("iPhone SE") } } }