最近新起了一個 side project,用於承載 WWDC19 裏公佈的內容,會用上如下技術棧:git
SwiftUI
作全部的表現層。Alamofire
+ SwiftyJSON
作全部的網絡層交互,本來想再上一個 Moya
,想了想,這個產品網絡層比較簡單,不必爲了上而上。SwiftUI
官方推薦作法來。Core Data
+ FileManager
管理全部數據緩存。SF Symbols
作全部的 icon。在完成了一些前期的工做後,最近在空閒時間發力實現項目中的「更多菜單」。「更多菜單」在 github 上搜索關鍵詞 contextMenu
/menu
,再限制語言,會出來一堆基於 UIKit
的實現,若是咱們想要基於 SwiftUI
實現一個符合 SwiftUI
風格的「更多菜單」要怎麼實現呢?github
UIKit
會怎麼作?在使用 SwiftUI
實現「更多菜單」以前,先看看使用 UIKit
怎麼實現。由於 UIKit
咱們都相對熟悉,大多數 API 也都知道,就不放實現細節了。算法
有兩種實現方式。若是是全屏「更多菜單」,可能會基於 CATransition
作一個「更多菜單」ViewController
動畫過渡出現,或者是基於 UIWindow
切換 keyWindow
動畫過渡出現,這是在作一個容器。swift
容器有了,內部咱們能夠基於 UITableView
或者 For
循環遍歷建立模擬出一個「列表」視圖出來,而後能夠用閉包的方式接收 ViewModel
的數據源配置傳遞,再經過閉包的方式把「更多菜單」中的點擊事件傳遞出去。緩存
建立出容器內部的「更多菜單」後,調整調整佈局約束,獲取屏幕寬高之類的位置操做,最後再封裝一個好用的 API,暴露給業務調用方,丟給 QA 等着反饋繼續調整就能夠了。封裝好的調用方式可能會以下所示(這是我自定義的一個選擇器組件網絡
PJPickerView.showPickerView(viewModel: {
$0.dataArray = [["PJHubs", "PJ", "皮筋"], ["培鈞", "阿鈞"]]
$0.titleString = "選擇你的暱稱"
}) {
print("選擇的暱稱是:\($0)")
print("選擇的索引爲:\($1.section)\($1.row)")
}
複製代碼
可是這種 UIKit
的思路直接套在 SwiftUI
上能「跑」得起來了嗎?閉包
SwiftUI
應該怎麼作?在說 SwiftUI
應該怎麼去實現一個「更多菜單」時,先假設咱們都已經熟悉了 SwiftUI
的基本語法,都跟着 Apple 官方的 SwiftUI Tutorials 摸索過一遍了。app
若是你跟我同樣也是一個從 UIKit
過來的選手,那麼咱們還會去這麼思考:ide
這看上去思路都是正確的。在個人一番實踐下來,確實是這個思路沒錯,可見 Apple 並無拋棄在 UIKit
裏養成的思惟習慣,可是,正準備上手作時,發現了一些奇怪的地方......佈局
從 UIKit
切換到 SwiftUI
後,咱們會發現 View
再也不是 UIView
,你甚至都沒法建立一個 Array<View>
這麼個視圖集合,可是在 SwiftUI
中卻一切都是 View
(除了那幾個基本的主視圖,如 Text
, Image
,Color
等。
咱們興致勃勃的用 VStack
和 HStack
,可能會再加上一個 ForEach
根據傳入的數據源,建立出了一個以下「更多菜單」的列表:
當咱們想要把這個「更多菜單」的原型放在首頁列表的導航欄上時,出現了一個問題,當咱們把菜單原型直接加到寫好的列表上時,它被全屏覆蓋了!
思考了一下,SwiftUI
中的 View
不是 UIView
,這點很是重要,並且要牢記在心!當咱們經過一個狀態變量去控制菜單的顯示和隱藏時,咱們加進去的是一個 View
,當它隱藏時,SwiftUI
只會渲染原先的列表;當它出現時,觸發了 SwiftUI
的 diff
算法,從新渲染應該渲染的部分。
那就算從新渲染,爲何會把原先的已經渲染出來的列表「弄沒了」呢?我翻了一圈沒有找到解答的資料,如下內容爲猜想:
首先咱們須要明確 SwiftUI
是「聲明式」佈局,當須要返回一個總體的 View
給 body
時,咱們卻返回了「一堆」View
,也就是菜單和列表。此時菜單 View
和列表 View
並非一個集合體,也就是咱們返回了兩個 View,但若是咱們把這個代碼鋪開來看,在 Swift 5.1
中當只有一個須要返回的值時,return
能夠省略。
但外部咱們卻只返回了一個 NavigationView
,知足了省略 return
的要求,但 NavigationView
內部的 content
內容集合由於缺失佈局致使列表雖執行了 DSL,但轉換成繪製信息時,丟失了繪製列表的數據。這也就說明了,爲何咱們在給 SwiftUI
斷點的時候停下了,但卻在 Xcode 的 Debug View Hierarchy
中未看到對應的視圖層級。
知道問題出在哪了之後,加上一個 VStack
,算是解決了這一個問題。
但實際上咱們會發現菜單和列表混在一個同一個層級上,回想使用 UIKit
實現菜單時,正如上文說的,咱們會使用 UIViewController
或者 UIWindow
把菜單和父視圖在縱座標上進行隔離,在 SwiftUI
中也是同樣的,因此咱們須要用上 ZStack
。
能夠發現使用了 ZStack
後仍是不行,再換回用 UIKit
的思路去想,咱們在使用 UIKit
去完成菜單時,是否是會去作切換視圖層級的操做?那在 SwiftUI
中怎麼切換視圖層級呢?
很遺憾,在 SwiftUI
中不能切換視圖層級,只能經過一個狀態變量值去控制某個視圖的顯示和隱藏,可是 SwiftUI
只是一個 DSL,最終仍是會被翻譯成渲染節點樹的麼,那麼能夠推測出菜單絕對是被列表給遮擋了。
所以只須要把菜單添加在列表下面便可。
咱們須要把列表調整到左上角,並加上箭頭。到這一步,咱們已經把原型給實現出來了,須要對庫進行一個封裝,包裝成一個 MenuView
供外部調用。
若是咱們直接給 VStack
設置 frame
是沒有效果的,由於 VStack
沒有「幾何邊界」,那麼咱們應該使用 GeometryReader
來包一層菜單視圖,並設置 GeometryReader
的 frame
便可。
也就是說,菜單如今的容器由 VStack
變爲了 GeometryReader
,此時咱們再去看 Debug View Hierarchy
,會發現菜單和列表都出如今了同一個視圖上,咱們只須要把菜單的容器變爲透明,而後給 GeometryReader
添加點擊事件來控制菜單的顯示和隱藏便可。
但這裏須要注意的是,在 SwiftUI
中,若是你給一個 View
的背景色爲 clear
,那這個 View
就不會被渲染出來了,所以要控制透明度爲 0.01
。
其實到如今若是把工程 build
起來一看,從 UI 上看效果差很少,若是是純文本菜單的話,基本上這一環節的內容就結束了,但由於我還還想用上 SF Symbols
,因此作了一個「左圖右字」的菜單。
讓我感到驚訝的是,SF Symbols
竟然不是規整的正方形圖標,直接不作任何處理丟到菜單上會發現每一行的圖和字都有了一些偏移。若是你直接調用 .resizable()
、.frame
和 .scaledToFill()
等方法,會發現圖標又變形了。
仍是那句話「計算機科學領域的任何問題均可以經過增長一個間接的中間層來解決」,Image
和 Text
套在一個 HStack
裏會出現上述問題,那就給 Image
再套一個 HStack
就行了,對 Image
進行約束限制。
這是我已經封裝好的菜單 cell 組件(能夠 ForEach
直接弄完:
struct MASSquareMenuCell<Content: View>: View {
var itemName: String
var itemImageName: String
var content: () -> Content
var body: some View {
NavigationLink(destination: content()) {
HStack {
// 限定 `Image`
HStack {
Image(systemName: itemImageName)
.imageScale(.medium)
.foregroundColor(.white)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 20))
}
.frame(width: 50)
Text(itemName)
Spacer()
}
.foregroundColor(.white)
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
.frame(width: 130)
}
}
}
複製代碼
佈局約束設置好了,就剩下塞數據了。由於 SwiftUI
所用的開發流程和我以前的開發流程差異挺大的,尤爲是數據流這一塊,看了 github 上幾個項目後才明白大概是怎麼回事。
跟 mentor 討論了一下關於相似這種菜單組件是作成一個 UI 組件仍是業務組件,最後得出的結論是還得看業務具體的需求,若是作的就是一個存粹的 UI 組件,那每個菜單項的點擊都要暴露給管理其生命週期的擁有者,若是這個組件作的事情比較封閉,留給業務調用方自定義的操做並很少,並且也確實是作到了一行代碼或者比較簡單的配置就能夠接入,那作成純業務組件也何嘗不可。
首先說明,我以前在其它的 side project 中也有實現過相似的「更多菜單」,可是當時由於 UIKit
和實習公司代碼風格的影響,我養成了一個不論是什麼組件,總以外部表現出是 UI 組件,那就一股腦的全都是 UI 組件。但 SwiftUI
所推崇的開發模式引起了我對上的思考。
最終通過個人一番整理後,同時也遵循「過早的優化是魔鬼」的原則,雜糅了業務和 UI 兩種組件模式,肯定了菜單上的每個選項點擊都是要經過 NavigationLink
進行跳轉,而後我須要暴露一個閉包讓調用方填入菜單中的每一個選項的視圖。
剛開始個人想法很是簡單,仍是按照 UIkit
的那一套思想,新建一個菜單數據源中間件,調用方能夠動態的增刪菜單中的選項,這種模式沒有錯,但問題 SwiftUI
不支持這麼作。
菜單選項中的 itemName
和 itemImageName
左圖右字的配置選項很容易思考出結果,但 itemView
難道是繼承 View
嗎?很明顯不行,由於 View
是一個協議,那若是我繼承 View
實現一個類或者結構體呢?別忘了,實現 View
協議你須要把 body
屬性也聲明好了,但動態增刪選項的目的就是要動態不一樣的 View
內容呀~
換回去 UIkit
的想法,若是咱們想要實現一個菜單數據源模型,可能會這麼寫:
struct MenuModel {
var itemName: String
var itemImageName: String
var itemView: UIView
}
複製代碼
咱們已經顯式的指明 itemView
的類型爲 UIView
了,在 SwiftUI
中,要達到這個效果其實也是同樣,既然咱們不能規避不聲明 View
的 body
屬性,那就去實現它好了,完整的菜單代碼以下所示:
//
// MASSquareMenuView.swift
// masq
//
// Created by 翁培鈞 on 2019/8/2.
// Copyright © 2019 PJHubs. All rights reserved.
//
import SwiftUI
struct MASSquareMenuCell<Content: View>: View {
var itemName: String
var itemImageName: String
var content: () -> Content
var body: some View {
NavigationLink(destination: content()) {
HStack {
// 限定 `Image`
HStack {
Image(systemName: itemImageName)
.imageScale(.medium)
.foregroundColor(.white)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 20))
}
.frame(width: 50)
Text(itemName)
Spacer()
}
.foregroundColor(.white)
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
.frame(width: 130)
}
}
}
struct MASSquareMenuView<Content: View>: View {
@Binding var isShowMenu: Bool
var content: () -> Content
var body: some View {
GeometryReader { _ in
// 頂部箭頭
Image(systemName: "triangle.fill")
.padding(EdgeInsets(top: 5, leading: 25, bottom: 0, trailing: 0))
VStack(alignment: .leading) {
self.content()
}
.background(Color.black)
.cornerRadius(5)
.padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 0))
Spacer()
}
.background(Color.white.opacity(0.01))
.frame(minWidth: UIScreen.main.bounds.width, minHeight: UIScreen.main.bounds.height)
.onTapGesture {
self.isShowMenu.toggle()
}
}
}
複製代碼
你們能夠參考 VStack
的聲明實現,看看它是怎麼實現接收多 View
參數的~實際上從代碼中能夠看出使用了 @ViewBuilder
,而 @ViewBuilder
是 @_functionBuilder
關鍵字修飾的結構體,這部分細節能夠看喵神的這篇文章。
而咱們只須要按照下圖的方式進行調用,就能夠優雅的完成菜單數據源填入了。
仍是那句話,這是我用於承載 WWDC19 新推出的各類 framework 的 side project,對不少東西的認識也在不斷的發展中,從 beta1 到 beta5 我幾乎看了 github 上公開的與 SwiftUI
有關的 60% 的 repo,你們都在改 Apple 的官方 demo,並且有一些相似與「更多菜單」的實際問題並無人去解決,大部分都在作各類「TODO-list」的變種。
這個項目還沒寫完,甚至纔剛開始,在一些點子的實現上由於 SwiftUI
太新了,我想了解或者相似的需求都沒有能夠借鑑的地方,只能說頂住了不少本身給本身的壓力。
Custom view won't use state variable update provided through binding, but debug watch shows changes
How do I create a multiline TextField in SwiftUI?
項目地址:Masq iOS 客戶端
PJ的開發平常:PJ的開發平常