第二十三章 UIHostingController|withAnimation|SwiftUI 默认动画时间

0 阅读4分钟

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
                    }
                }
        }
    }
}

Kapture 2021-12-06 at 11.32.36

我们通过 UIViewController 进行弹出,我们的动画终于正常了。但是消失呢?因为通过 取消和确定的按钮都能进行取消。我们简单的画一下功能流程图。

image-20211206115250607

对于消失我们就需要先让 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) {
                    ...
                }
            }
            ...
        }
        ...
    }
}

Kapture 2021-12-06 at 14.37.31

默认动画时间从 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()
        }
    }
}

Kapture 2021-12-06 at 14.44.34

这样看起来动画正常了,偷偷改了默认动画时间,有点坑。

此时我们封装的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]
    }
    ...
}

Kapture 2021-12-06 at 15.30.45

但是选择完毕之后,我们的界面没有更新,因为我们开始用到的是 AppConfig的缓存的数据。当 currentWorkshop 值改变的时候,我们改变一下 AppConfig.share.workShopCode 的值。

class MyPageViewModel: BaseViewModel {
    ...
    /// 当前选中的车间
    @Published var currentWorkshop:GetAllWorkshopResponse? {
        didSet {
            AppConfig.share.workShopCode = currentWorkshop?.workshopCode
        }
    }
    ...
}

这样我们选择车间功能就封装完毕了。