UIViewController 自定义 Sheet
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: ["1","2"],
isShow: $viewModel.isShowDataPicker)
.background(.white)
}
}
...
}
}
...
}
...
}
UIHostingController 调用 SwiftUI 视图 withAnimation 默认动画
我们将使用 UIViewController 弹出封装在 DataPickerManager 里面调用。
class DataPickerManager {
/// 做成单利对象是为了记录当前弹出的 UIViewController 方便随意的调用消失
static let manager = DataPickerManager()
/// 当前展示 Data Picker 的控制器
private var currentShowDataPickerController:UIViewController?
/// show 方法采用 @ViewBuilder 获取自定义的视图
func show<Content:View>(@ViewBuilder _ content:() -> Content) {
/// 将自定义的视图封装为 DataPickerContentView 为了封装动画的弹出和消失
let contentView = DataPickerContentView(content: content)
/// 使用 UIHostingController 来展示 SwiftUI 的试图
let controller = UIHostingController(rootView: contentView)
/// 设置界面弹出方式为 overFullScreen 是支持设置界面半透明
controller.modalPresentationStyle = .overFullScreen
/// 设置背景为黑色半透明
controller.view.backgroundColor = .black.withAlphaComponent(0.6)
guard let rootViewController = keyWindow()?.rootViewController else {return}
/// 保存当前正在展示的模态试图 方便进行消失
currentShowDataPickerController = controller
rootViewController.present(controller, animated: false, completion: nil)
}
func dismiss() {
guard let currentShowDataPickerController = self.currentShowDataPickerController else {
return
}
currentShowDataPickerController.dismiss(animated: false, completion: nil)
self.currentShowDataPickerController = nil
}
/// 获取当前的 KeyWindow 在 iOS15上面 采用 UIWindowScene 获取
private func keyWindow() -> UIWindow? {
if #available(iOS 15.0, *) {
return UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow})
.first
} else {
return UIApplication.shared.windows
.filter({$0.isKeyWindow})
.first
}
}
}
/// 采用 PreferenceKey 获取 自定义视图的高度
fileprivate struct DataPickerSizeKey: PreferenceKey {
static var defaultValue: [CGSize] = []
static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
/// 为什么要通过数组合并? 尝试不通过这种方式 有的视图在外层获取不到大小
value.append(contentsOf: nextValue())
}
}
/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
private let content:Content
/// 自定义视图的大小 需要消失的时候用到 所以需要进行保存
@State private var size:CGSize = .zero
/// 当前将自定义视图进行弹出的偏移量 通过偏移量的变更 来执行动画
@State private var offsetY:CGFloat = .zero
/// 初始化 Content 用户需要展示的自定义视图
init(@ViewBuilder content:() -> Content) {
self.content = content()
}
var body: some View {
VStack {
Spacer()
content
.background {
/// 可以通过封装在 background 或者 overlay 里面通过 geometry 获取试图的大小
GeometryReader { geometry in
Color.clear
/// 将获取的大小保存在 Preference 里面 ,向上进行传递
.preference(key: DataPickerSizeKey.self, value: [geometry.size])
}
}
/// 监听获取的视图的大小的变更
.onPreferenceChange(DataPickerSizeKey.self, perform: { value in
/// 可能获取不到 就直接中断执行
guard let size = value.first else {return}
/// 将获取的大小保存下来
self.size = size
/// 更改偏移量 用于 .offset 设置偏移量
offsetY = size.height
})
/// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
.offset(x: 0, y: offsetY)
.onAppear {
/// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
withAnimation(.linear) {
offsetY = 0
}
}
}
}
}
我们通过 UIViewController 进行弹出,我们的动画终于正常了。但是消失呢?因为通过 取消和确定的按钮都能进行取消。我们简单的画一下功能流程图。
对于消失我们就需要先让 DataPickerContentView 执行消失动画,之后再让当前的 UIHostController 移除。但是外界的操作怎么通知到
DataPickerContentView 之后做消失动画呢?
为了做到信息互通,我们采用 ViewModel 的方式。
class DataPickerManager {
...
/// 通过 DataPickerContentViewModel 进行控制动画的弹出和消失
private let viewModel:DataPickerContentViewModel = DataPickerContentViewModel()
...
func dismiss() {
/// 当 DataPickerContentView 动画消失之后 再让界面消失
viewModel.endAnimation {[weak self] in
...
}
}
...
}
class DataPickerContentViewModel: ObservableObject {
/// 将 offsetY 转移到 DataPickerContentViewModel @Published 用于外界属性观察
@Published var offsetY:CGFloat = 0
/// 自定义视图大小 因为不需要观察 只需要进行存储 就设置为私有属性
private var contentSize:CGSize = .zero
/// 外界不需要进行读取 contentSize 我们就只写了更新 contentSize 方法
func updateContentSize(size:CGSize) {
contentSize = size
offsetY = size.height
}
/// 执行动画就只需要 offsetY = 0
func startAnimation() {
withAnimation(.linear) {
offsetY = 0
}
}
/// 结束动画就需要将 offsetY = contentSize.height
func endAnimation(completion:@escaping () -> Void) {
withAnimation(.linear) {
offsetY = contentSize.height
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
completion()
}
}
}
/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
...
@ObservedObject private var viewModel:DataPickerContentViewModel
/// 初始化 Content 用户需要展示的自定义视图
init(viewModel:DataPickerContentViewModel, @ViewBuilder content:() -> Content) {
self.viewModel = viewModel
...
}
var body: some View {
VStack {
Spacer()
content
...
/// 监听获取的视图的大小的变更
.onPreferenceChange(DataPickerSizeKey.self, perform: { value in
/// 可能获取不到 就直接中断执行
guard let size = value.first else {return}
viewModel.updateContentSize(size: size)
})
/// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
.offset(x: 0, y: viewModel.offsetY)
.onAppear {
/// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
viewModel.startAnimation()
}
}
}
}
我们此前的 PickerSheet 组件没有暴露出,取消按钮事件和确定按钮事件。我们调整代码暴露出来,我们觉得既然是事件还是通过闭包代理出来设计比较好。
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: ["1","2"],
cancelHandle: {
DataPickerManager.manager.dismiss()
},
confirmHandle: {
DataPickerManager.manager.dismiss()
})
.background(.white)
}
}
...
}
}
...
}
...
}
struct PickerSheet<Item:DataPickerItem>: View {
...
/// 点击取消按钮回掉
typealias CancelHandle = () -> Void
private let cancelHandle:CancelHandle
/// 点击确定按钮回掉
typealias ConfirmHandle = () -> Void
private let confirmHandle:ConfirmHandle
init(title:String,
items:[Item],
cancelHandle:@escaping CancelHandle,
confirmHandle:@escaping ConfirmHandle) {
...
self.cancelHandle = cancelHandle
self.confirmHandle = confirmHandle
}
var body: some View {
VStack {
...
HStack {
Button(action:cancelHandle) {
...
}
Button(action: confirmHandle) {
...
}
}
...
}
...
}
}
默认动画时间从 0.25 变为 0.35
但是看起来,消失动画感觉还没有消失,这个界面就消失了?那么可能0.25秒默认时间不对,我们看了一下API。
public static func timingCurve(_ c0x: Double, _ c0y: Double, _ c1x: Double, _ c1y: Double, duration: Double = 0.35) -> Animation
果然默认的动画时间变成了0.35秒。
class DataPickerContentViewModel: ObservableObject {
...
/// 结束动画就需要将 offsetY = contentSize.height
func endAnimation(completion:@escaping () -> Void) {
...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
completion()
}
}
}
这样看起来动画正常了,偷偷改了默认动画时间,有点坑。
此时我们封装的Modal的弹出和消失已经封装完毕,但是我们选择工厂点击确定,我们 却无法拿到数据,我们通过传入 @Binding可以让 PickerSheet 组件内部进行设置。
struct MyPage: View {
...
var body: some View {
PageContentView(title: "我的", viewModel: viewModel) {
VStack(spacing: 0) {
...
workshopCell()
.onTapGesture {
DataPickerManager.manager.show {
PickerSheet(title: "工厂",
items: viewModel.workShops,
selectItem: $viewModel.currentWorkshop,
cancelHandle: {
...
},
confirmHandle: {
...
})
...
}
}
...
}
}
...
}
...
}
class MyPageViewModel: BaseViewModel {
...
/// 当前选中的车间
@Published var currentWorkshop:GetAllWorkshopResponse?
...
}
struct PickerSheet<Item:DataPickerItem>: View {
...
/// 当前选中的 Item
@Binding private var selectItem:Item?
...
init(title:String,
items:[Item],
selectItem:Binding<Item?>,
cancelHandle:@escaping CancelHandle,
confirmHandle:@escaping ConfirmHandle) {
...
self._selectItem = selectItem
...
}
var body: some View {
VStack {
...
DataPickerView(items: items, selectItem: $selectItem)
...
}
...
}
}
struct DataPickerView<Item:DataPickerItem>: UIViewRepresentable {
...
@Binding private var selectItem:Item?
init(items:[Item], selectItem:Binding<Item?>) {
...
self._selectItem = selectItem
}
func makeCoordinator() -> DataPickerViewCoordinator<Item> {
return DataPickerViewCoordinator(items: items, selectItem: $selectItem)
}
func makeUIView(context: Context) -> UIPickerView {
...
if let selectItem = selectItem,
let index = items.firstIndex(where: {$0 == selectItem }) {
picker.selectRow(index, inComponent: 0, animated: false)
}
...
}
...
}
class DataPickerViewCoordinator<Item:DataPickerItem>: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
...
@Binding private var selectItem:Item?
init(items:[Item], selectItem:Binding<Item?>) {
...
self._selectItem = selectItem
}
...
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
selectItem = items[row]
}
...
}
但是选择完毕之后,我们的界面没有更新,因为我们开始用到的是 AppConfig的缓存的数据。当 currentWorkshop 值改变的时候,我们改变一下 AppConfig.share.workShopCode 的值。
class MyPageViewModel: BaseViewModel {
...
/// 当前选中的车间
@Published var currentWorkshop:GetAllWorkshopResponse? {
didSet {
AppConfig.share.workShopCode = currentWorkshop?.workshopCode
}
}
...
}
这样我们选择车间功能就封装完毕了。