SwiftUI 怎么实现一个「更多菜单」?

在 WWDC 现场与 SwiftUI

前言

最近新起了一个 side project,用于承载 WWDC19 里公布的内容,会用上如下技术栈:git

  1. SwiftUI 作全部的表现层。
  2. Alamofire + SwiftyJSON 作全部的网络层交互,本来想再上一个 Moya,想了想,这个产品网络层比较简单,不必为了上而上。
  3. SPM 管理全部三方依赖,就目前使用状况来看,比 pod 体验持平,会继续使用。
  4. 仍是使用 MVC,可是这次的 MVC 只是简单的「模块划分」而已,workflow 和 dataflow 都尽量的跟着 SwiftUI 官方推荐作法来。
  5. 使用 Core Data + FileManager 管理全部数据缓存。
  6. 使用 SF Symbols 作全部的 icon。
  7. 由于另一个侧重点在 dark mode,因此使用 Group 设置 light mode 和 dark mode 两种模式,直接预览。

在完成了一些前期的工做后,最近在空闲时间发力实现项目中的「更多菜单」。「更多菜单」在 github 上搜索关键词 contextMenu/menu,再限制语言,会出来一堆基于 UIKit 的实现,若是咱们想要基于 SwiftUI 实现一个符合 SwiftUI 风格的「更多菜单」要怎么实现呢?github

UIKit 会怎么作?

在使用 SwiftUI 实现「更多菜单」以前,先看看使用 UIKit 怎么实现。由于 UIKit 咱们都相对熟悉,大多数 API 也都知道,就不放实现细节了。算法

  1. 有两种实现方式。若是是全屏「更多菜单」,可能会基于 CATransition 作一个「更多菜单」ViewController 动画过渡出现,或者是基于 UIWindow 切换 keyWindow 动画过渡出现,这是在作一个容器swift

  2. 容器有了,内部咱们能够基于 UITableView 或者 For 循环遍历建立模拟出一个「列表」视图出来,而后能够用闭包的方式接收 ViewModel 的数据源配置传递,再经过闭包的方式把「更多菜单」中的点击事件传递出去。缓存

  3. 建立出容器内部的「更多菜单」后,调整调整布局约束,获取屏幕宽高之类的位置操做,最后再封装一个好用的 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

  1. 建立一个容器。
  2. 建立容器里的「列表」视图。
  3. 经过一个状态变量去控制「更多菜单」的显示和隐藏。
  4. 暴露出一个闭包告诉「更多菜单」依赖的父视图点击了哪一个选项。

这看上去思路都是正确的。在个人一番实践下来,确实是这个思路没错,可见 Apple 并无抛弃在 UIKit 里养成的思惟习惯,可是,正准备上手作时,发现了一些奇怪的地方......布局

如何建立一个容器?

UIKit 切换到 SwiftUI 后,咱们会发现 View 再也不是 UIView,你甚至都没法建立一个 Array<View> 这么个视图集合,可是在 SwiftUI 中却一切都是 View(除了那几个基本的主视图,如 TextImageColor 等。

咱们兴致勃勃的用 VStackHStack,可能会再加上一个 ForEach 根据传入的数据源,建立出了一个以下「更多菜单」的列表:

快速的写完了「更多菜单」的原型

当咱们想要把这个「更多菜单」的原型放在首页列表的导航栏上时,出现了一个问题,当咱们把菜单原型直接加到写好的列表上时,它被全屏覆盖了!

出现了一个问题

思考了一下,SwiftUI 中的 View 不是 UIView,这点很是重要,并且要牢记在心!当咱们经过一个状态变量去控制菜单的显示和隐藏时,咱们加进去的是一个 View,当它隐藏时,SwiftUI 只会渲染原先的列表;当它出现时,触发了 SwiftUIdiff 算法,从新渲染应该渲染的部分。

那就算从新渲染,为何会把原先的已经渲染出来的列表「弄没了」呢?我翻了一圈没有找到解答的资料,如下内容为猜想:

首先咱们须要明确 SwiftUI 是「声明式」布局,当须要返回一个总体的 Viewbody 时,咱们却返回了「一堆」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 仍是不行?

能够发现使用了 ZStack 后仍是不行,再换回用 UIKit 的思路去想,咱们在使用 UIKit 去完成菜单时,是否是会去作切换视图层级的操做?那在 SwiftUI 中怎么切换视图层级呢?

很遗憾,在 SwiftUI 中不能切换视图层级,只能经过一个状态变量值去控制某个视图的显示和隐藏,可是 SwiftUI 只是一个 DSL,最终仍是会被翻译成渲染节点树的么,那么能够推测出菜单绝对是被列表给遮挡了。

只是被遮盖了

所以只须要把菜单添加在列表下面便可。

修复完成

调整列表约束

咱们须要把列表调整到左上角,并加上箭头。到这一步,咱们已经把原型给实现出来了,须要对库进行一个封装,包装成一个 MenuView 供外部调用。

若是咱们直接给 VStack 设置 frame 是没有效果的,由于 VStack 没有「几何边界」,那么咱们应该使用 GeometryReader 来包一层菜单视图,并设置 GeometryReaderframe 便可。

也就是说,菜单如今的容器由 VStack 变为了 GeometryReader,此时咱们再去看 Debug View Hierarchy,会发现菜单和列表都出如今了同一个视图上,咱们只须要把菜单的容器变为透明,而后给 GeometryReader 添加点击事件来控制菜单的显示和隐藏便可。

但这里须要注意的是,在 SwiftUI 中,若是你给一个 View 的背景色为 clear,那这个 View 就不会被渲染出来了,所以要控制透明度为 0.01

其实到如今若是把工程 build 起来一看,从 UI 上看效果差很少,若是是纯文本菜单的话,基本上这一环节的内容就结束了,但由于我还还想用上 SF Symbols,因此作了一个「左图右字」的菜单。

让我感到惊讶的是,SF Symbols 竟然不是规整的正方形图标,直接不作任何处理丢到菜单上会发现每一行的图和字都有了一些偏移。若是你直接调用 .resizable().frame.scaledToFill() 等方法,会发现图标又变形了。

仍是那句话“计算机科学领域的任何问题均可以经过增长一个间接的中间层来解决”,ImageText 套在一个 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 所推崇的开发模式引起了我对上的思考。

与 mentor 讨论的草稿

最终通过个人一番整理后,同时也遵循「过早的优化是魔鬼」的原则,杂糅了业务和 UI 两种组件模式,肯定了菜单上的每个选项点击都是要经过 NavigationLink 进行跳转,而后我须要暴露一个闭包让调用方填入菜单中的每一个选项的视图。

刚开始个人想法很是简单,仍是按照 UIkit 的那一套思想,新建一个菜单数据源中间件,调用方能够动态的增删菜单中的选项,这种模式没有错,但问题 SwiftUI 不支持这么作。

菜单选项中的 itemNameitemImageName 左图右字的配置选项很容易思考出结果,但 itemView 难道是继承 View 吗?很明显不行,由于 View 是一个协议,那若是我继承 View 实现一个类或者结构体呢?别忘了,实现 View 协议你须要把 body 属性也声明好了,但动态增删选项的目的就是要动态不一样的 View 内容呀~

换回去 UIkit 的想法,若是咱们想要实现一个菜单数据源模型,可能会这么写:

struct MenuModel {
    var itemName: String
    var itemImageName: String
    var itemView: UIView
}
复制代码

咱们已经显式的指明 itemView 的类型为 UIView 了,在 SwiftUI 中,要达到这个效果其实也是同样,既然咱们不能规避不声明 Viewbody 属性,那就去实现它好了,完整的菜单代码以下所示:

//
// 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 关键字修饰的结构体,这部分细节能够看喵神的这篇文章

而咱们只须要按照下图的方式进行调用,就能够优雅的完成菜单数据源填入了。

优雅的「更多菜单」API

后记

仍是那句话,这是我用于承载 WWDC19 新推出的各类 framework 的 side project,对不少东西的认识也在不断的发展中,从 beta1 到 beta5 我几乎看了 github 上公开的与 SwiftUI 有关的 60% 的 repo,你们都在改 Apple 的官方 demo,并且有一些相似与「更多菜单」的实际问题并无人去解决,大部分都在作各类「TODO-list」的变种。

这个项目还没写完,甚至才刚开始,在一些点子的实现上由于 SwiftUI 太新了,我想了解或者相似的需求都没有能够借鉴的地方,只能说顶住了不少本身给本身的压力。

参考资料

SwiftUI 的一些初步探索 (一)

SwiftUI 的一些初步探索 (二)

Custom view won't use state variable update provided through binding, but debug watch shows changes

hite/YanxuanHD

Fucking SwiftUI

SwiftUI 数据流

How do I create a multiline TextField in SwiftUI?

SwiftUI 背后那些事儿

SWIFTUI BY EXAMPLE

项目地址:Masq iOS 客户端

PJ的开发平常:PJ的开发平常

相关文章
相关标签/搜索