最近新起了一个 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的开发平常