第二十九章 修复首页 PopMenuView 显示问题

在首页切换工厂的时候,我们发现了一处严重的UI问题。

本来我们做的PopMenuButton竟然被导航栏遮挡在最下面。出现的原因在于,我们无法确保我们的PopMenuView一定在最外面,因此可能被其他外层遮挡。为了确保PopMenuView一定会在最外层弹出,我们只能弹出一个 UIViewController,这样保证一定出现在最外层。

我们只需要获取到offset Y的高度即可,这个值也是PopMenuButton对应的在Golbal对应的offset y

对于获取视图在对应视图的位置,我们可以使用GeometryReader。今天在测试通过PreferenceKey传递获取的偏移量时候,意外试验出一个BUG

通过 PreferenceKey 获取指定视图的偏移量

1 创建 PreferenceKey

swift 复制代码
struct TextPointKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

2 组件获取 Point

swift 复制代码
Text("Hello World!")
		/// 一般使用 `background`其实也可以使用 `overlay`
    .background {
        /// 使用  `GeometryReader` 获取父试图的大小
        GeometryReader { geometry in
            /// 使用 透明颜色 是为了不污染界面
            Color.clear
                /// 通过`GrometryProxy`的`frame`方法可以获取对应的位置
                /// 保存在 `Preference`中
                .preference(key: TextPointKey.self,
                            value: geometry.frame(in: .global).origin)
        }
    }

3 通过 onPreferenceChange 获取刚才设置的值

swift 复制代码
Text("Hello World!")
		/// 一般使用 `background`其实也可以使用 `overlay`
    .background {
        ...
    }
    .onPreferenceChange(TextPointKey.self) { point in
        print(point.debugDescription)
    }

此时我们运行可以看到有下面打印信息。

shell 复制代码
(148.5, 408.1666666666667)

上述的方法进行使用会可能引起获取不到的Bug,关于这个Bug的研究可以看下面的文章。

\]: [xiaozhuanlan.com/topic/74531...](https://link.juejin.cn?target=https%3A%2F%2Fxiaozhuanlan.com%2Ftopic%2F7453126890 "https://xiaozhuanlan.com/topic/7453126890") "关于 SwiftUI 通过 Preference 获取视图 Frame 的隐藏 BUG 探索" ## 获取`PopMenuButton`对应`global`的`point` ### 1 新增`PreferenceKey` ```swift struct PopMenuPointKey: PreferenceKey { static var defaultValue: [CGPoint] = [] static func reduce(value: inout [CGPoint], nextValue: () -> [CGPoint]) { value.append(contentsOf: nextValue()) } } ``` ### 2 获取`选择工厂`组件的`Point` ```swift struct HomePage: View { ... var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { HStack(spacing:6) { ... } .background(content: { GeometryReader { geometry in Color.clear .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global).origin]) } }) .onPreferenceChange(PopMenuPointKey.self, perform: { points in print(points.debugDescription) }) ... } trailingBuildeder: { ... } ... } } ``` ```shell [(16.0, 60.0)] [(16.000000000000007, 60.0)] [(16.0, 60.0)] ``` 打印了三次,打印多次,这就是使用数组的弊端吧。 ## 保存获取到的`Point` 为了能够让我们弹出一个`UIViewController`可以定位到,我们需要将这个`Point`保存下来,我们需要新增一个`@State`变量存起来。 ```swift struct HomePage: View { ... @State private var popMenuButtonOffset:CGPoint = .zero var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { HStack(spacing:6) { ... } ... .onPreferenceChange(PopMenuPointKey.self, perform: { points in guard let point = points.first else { return } popMenuButtonOffset = point }) ... } trailingBuildeder: { EmptyView() } ... } } ``` ## 新增 View 展示 PopMenuView ```swift struct PopMenuContentView: View { /// 数据源 private let items:[T] /// `PopMenuButton`的`Offset` private let offset:CGPoint /// 当前选中的数据源 @Binding private var currentItem:T init(items:[T], offset:CGPoint, currentItem:Binding) { self.items = items self.offset = offset self._currentItem = currentItem } var body: some View { GeometryReader { geometry in popMenuButton .offset(x: 0, y: offset.y) } } private var popMenuButton: some View { PopMenuButton(items: items, currentItem: $currentItem) {item in currentItem = item } } } ``` ## 使用`UIHostingController`展示工厂列表 ```swift struct HomePage: View { ... @State private var popMenuButtonOffset:CGPoint = .zero var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { HStack(spacing:6) { ... } ... .onTapGesture { let rootView = PopMenuContentView(items: viewModel.factoryList, offset: popMenuButtonOffset, currentItem: $viewModel.currentFactory) let controller = UIHostingController(rootView: rootView) controller.modalPresentationStyle = .overFullScreen controller.view.backgroundColor = .clear let rootWindow:UIWindow? if #available(iOS 13.0, *) { rootWindow = UIApplication.shared.connectedScenes .filter({$0.activationState == .foregroundActive}) .compactMap({$0 as? UIWindowScene}) .first?.windows .filter({$0.isKeyWindow}) .first } else { rootWindow = UIApplication.shared.windows.filter({$0.isKeyWindow}).first } rootWindow?.rootViewController?.present(controller, animated: false, completion: nil) } } trailingBuildeder: { ... } ... } } ``` ## 封装获取`Key Window`的获取方法 我们在弹出了`UIHostingController`代码的时候,我们再次写了获取`Key Window`的代码,这是我们第二次用到,我们可以将获取`Key Window`进行封装,方便我们后续的使用。 ```swift struct App { static var keyWindow:UIWindow? { if #available(iOS 13.0, *) { return UIApplication.shared.connectedScenes .filter({$0.activationState == .foregroundActive}) .compactMap({$0 as? UIWindowScene}) .first?.windows .filter({$0.isKeyWindow}) .first } else { return UIApplication.shared.windows .filter({$0.isKeyWindow}) .first } } } ``` ### 替换掉工程现有获取`Key Window`的方法 #### DataPickerManager ```swift class DataPickerManager { ... /// show 方法采用 @ViewBuilder 获取自定义的视图 func show(@ViewBuilder _ content:() -> Content) { /... guard let rootViewController = App.keyWindow?.rootViewController else {return} ... } ... } ``` ```swift struct HomePage: View { ... var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { HStack(spacing:6) { ... } ... .onTapGesture { ... App.keyWindow?.rootViewController?.present(controller, animated: false, completion: nil) } } trailingBuildeder: { ... } ... } } ``` ## 修复偏移问题 ![image-20211216112926889](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/4fed00382d65fdc08dfd74aab651a5f3.webp) 修改完毕,我们运行之后发现,这偏移的位置和也太远了。为了探明原因,我们修改一下`PopMenuContentView`背景颜色,看一下问题所在。 ```swift struct PopMenuContentView: View { ... var body: some View { GeometryReader { geometry in ... } .background(.blue) } ... } ``` ![image-20211216115621948](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/bd56a804cebcfa59163af5c3c1c469ee.webp) 发现`PopMenuContentView`是完全铺满的,不是因为安全距离造成的。那是不是偏移量导致的吗?我们去掉`offset`. ```swift struct PopMenuContentView: View { ... var body: some View { GeometryReader { geometry in popMenuButton } .background(.blue) } ... } ``` ![image-20211216133502604](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/89b576f025bbd425f7f0c09a0a795cc9.webp) 我们去掉`offset`之后,竟然布局好像从安全距离开始的。我们就忽略掉安全距离,再次试一下。 ```swift struct PopMenuContentView: View { ... var body: some View { GeometryReader { geometry in popMenuButton } .ignoresSafeArea() .background(.blue) } ... } ``` ![image-20211216133756258](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/15c0d2b71e3bfb01a1b3db5dc2e7a816.webp) 这个就符合我们的预期了。我们将代码恢复,运行,我们的组件已经布局正常了。 ![image-20211216134027691](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/98b3d07db7a3c6ab3631fbf78d18772e.webp) 此时凭空出现的`PopMenuView`显得十分的突兀,我们不妨让`PopMenuView`显示在`PopMenuButton`的下来会好的多。 ### 修改`PopMenuPointKey`值为`[CGRect]` #### `PopMenuPointKey` ```swift struct PopMenuPointKey: PreferenceKey { static var defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { value.append(contentsOf: nextValue()) } } ``` #### `HomePage` ```swift struct HomePage: View { ... var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { ... .background(content: { GeometryReader { geometry in Color.clear .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)]) } }) .onPreferenceChange(PopMenuPointKey.self, perform: { rects in guard let rect = rects.first else { return } popMenuButtonOffset = CGPoint(x: rect.minX, y: rect.maxY) }) ... } trailingBuildeder: { EmptyView() } ... } } ``` ![image-20211216134857931](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/b63a2868585b318d182a974fcabf0e45.webp) 此时我们的界面看起来好一些,但是还是很丑。 ## 封装`PopMenu` 刚才经过我们一阵的修改,功能实现了,但是需要实现这个功能,需要改很多的东西。最好体验就是封装一个组件,可以自定义`PopMenuButton`和自定义`PopMenuView`。通过一个变量控制`UIHostingController`显示和隐藏。 类似这样伪代码 ```swift Button("show Menu") .popMenu(isShow:$isShow) { PopMenuView } ``` ### 改造`PopMenuContentView` 我们要做的让`PopMenuContentView`现实的内容可以用户自定义,参数`offset`保持不变。 ```swift struct PopMenuContentView: View { ... /// 内容视图 private let content:Content init(offset:CGPoint, @ViewBuilder content:() -> Content) { ... self.content = content() } var body: some View { GeometryReader { geometry in content ... } ... } } ``` ### 封装`.popMenu`方法 ```swift struct PopMenuViewModify: ViewModifier { @Binding private var isShow:Bool init(isShow:Binding) { _isShow = isShow } func body(content: Content) -> some View { content .background(content: { GeometryReader { geometry in Color.clear .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)]) } }) } } ``` ### 保存获取到的`Frame` ```swift struct PopMenuViewModify: ViewModifier { ... /// `PopMenuButton`的`Frame` @State private var contentFrame:CGRect = .zero ... func body(content: Content) -> some View { content ... .onPreferenceChange(PopMenuPointKey.self) { rects in guard let rect = rects.first else {return} contentFrame = rect } } } ``` ### 通过`onChange`监听`isShow`值的变动 ```swift struct PopMenuViewModify: ViewModifier { ... func body(content: Content) -> some View { content ... .onChange(of: isShow) { newValue in if newValue { /// 展示 `UIHostingController` } else { /// 隐藏 `UIHostingController` } } } } ``` ### 新增一个 `@ViewBuilder`设置 `PopMenuView` ```swift struct PopMenuViewModify: ViewModifier { ... /// 自定义 `PopMenuView`的闭包 private let contentBlock:() -> PopMenuView init(isShow:Binding, @ViewBuilder content:@escaping () -> PopMenuView) { ... contentBlock = content } func body(content: Content) -> some View { ... } } ``` ### 展示 `UIHostingController` ```swift struct PopMenuViewModify: ViewModifier { ... func body(content: Content) -> some View { content ... .onChange(of: isShow) { newValue in if newValue { /// 展示 `UIHostingController` show() } else { /// 隐藏 `UIHostingController` } } } private func show() { let offset = CGPoint(x: contentFrame.minX, y: contentFrame.maxY) let rootView = PopMenuContentView(offset: offset, content: { contentBlock() }) let controller = UIHostingController(rootView: rootView) controller.modalPresentationStyle = .overFullScreen controller.view.backgroundColor = .clear App.keyWindow?.rootViewController?.present(controller, animated: false, completion: nil) } } ``` ### 隐藏 `UIHostingController` 当我们进行隐藏时候发现,我们此时已经拿不到当前弹出的视图。 #### 通过 `presentedViewController`获取当前弹出的 `UIHostingController` ```swift var presentedViewController: UIViewController? { get } ``` > 当您使用 present(_:animated:completion:) 方法以模态方式(显式或隐式)呈现视图控制器时,调用该方法的视图控制器将此属性设置为它呈现的视图控制器。 如果当前视图控制器没有以模态方式呈现另一个视图控制器,则此属性中的值为 nil。 ```swift struct PopMenuViewModify: ViewModifier { ... private func dismiss() { let controller = App.keyWindow?.rootViewController?.presentedViewController controller?.dismiss(animated: false, completion: nil) } } ``` ### 封装 `View` 的扩展 ```swift extension View { func popMenu(isShow:Binding, @ViewBuilder content:@escaping () -> PopMenuView) -> some View { let modify = PopMenuViewModify(isShow: isShow, content: content) return self.modifier(modify) } } ``` ## 将封装好的`PopMenu`组件替换首页工厂功能 ```swift struct HomePage: View { ... @State private var isShowFactoryMenu:Bool = false ... var body: some View { PageContentView(title: "首页", viewModel: viewModel) { ... } leadingBuilder: { HStack(spacing:6) { ... } .popMenu(isShow: $isShowFactoryMenu, content: { PopMenuButton(items: viewModel.factoryList, currentItem: $viewModel.currentFactory) { item in viewModel.currentFactory = item isShowFactoryMenu = false } }) .onTapGesture { isShowFactoryMenu = true } } trailingBuildeder: { ...) } ... } } ``` 发现我们使用起来更加的简单方便。 ![0D07AB75-0887-484F-8BF7-684E23D93833-12013-000015F807E392BE](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/a85c624195b0d789bb5398919054256b.webp) ## 修改登录页面选择服务器组件 ```swift struct LoginPage: View { ... @StateObject private var appConfig:AppConfig = AppConfig.share var body: some View { ... { ... { ... ... { ServerSelectMenuView() ... .popMenu(isShow: $viewModel.isShowServerMenu) { PopMenuButton(items: viewModel.supportServerUrls, currentItem: $appConfig.currentAppServer) { item in appConfig.currentAppServer = item viewModel.isShowServerMenu = false } } .onTapGesture { viewModel.isShowServerMenu = true } ... } ... } ... } ... } } ``` ![7727EAFC-70E9-4001-AA74-A241C40B098A-12013-000016214B3C2D07](https://oss.xyyzone.com/jishuzhan/article/2025811698255462401/03f850472314018c59ca2097ce8c40bb.webp)

相关推荐
君赏3 小时前
第二十八章 重置 ObservableObject 模型数据
swiftui
君赏3 小时前
第二十七章 UINavigationBarAppearance|Divider
swiftui
君赏3 小时前
第二十二章 onAppear|DataPickerView
swiftui
君赏3 小时前
第二十三章 UIHostingController|withAnimation|SwiftUI 默认动画时间
swiftui
君赏3 小时前
第二十四章 init 方法初始化 State
swiftui
君赏3 小时前
第十九章 TabView|accentColor|AnyView|NavigationView|navigationTitle|navigationBarTit
swiftui
君赏3 小时前
第十七章 @MainActor
swiftui
君赏3 小时前
第十六章 RoundedRectangle|aspectRatio|UIViewRepresentable
swiftui
君赏3 小时前
第十八章 封装HUD和完善登录界面逻辑
swiftui