SwiftUI中弹窗实现

4,239 阅读7分钟

自定义弹窗

UIKit中 自定义弹窗方式很多,比如: 模态ViewControllerview覆盖keyWindow/RootWindow等。接下来我们看下SwiftUI中的弹窗方式。

1.自定义(使用ZStack)

SwiftUI中实现弹窗很简单,使用ZStack 进行组装,控制好对应的变量来显示隐藏 控件。

struct TestStack: View {
    
    @State var isShow = false
    
    var body: some View {
        ZStack{
            Text("大家来了 ")
            if isShow {
                Text("你好,哈哈哈").transition(.scale).zIndex(1)
            }
        }
    }
}

如上面的代码,是一个很简单的示例,通过变量来控制Text是否显示。对于动画执行时间和动画需要自定义。这样来说对于一个界面是好处理的,但是在SwiftUI中涉及到复杂界面和交互的时候问题很多。

接下来 我们使用这一特性来做一个复杂点的业务。我们知道在一个界面中可能涉及到很多弹窗,身份的确认,权限确认等等,在下面的页面中我们来做一个界面中 对多个弹窗的处理。

实现步骤

  • 1.通过ZStack来进行包装
  • 2.构建一个统一的黑底透明的背景
  • 3.自定义ObservableObject来控制弹窗展示和隐藏,ObservableObject作为一个EnvironmentObject
  • 4.自定义ViewModifier 来加载弹窗
  • 5.ObservableObject监听键盘弹出和收起,在键盘弹出时 先收起键盘 再次点击 收起弹窗
1.统一弹窗背景 颜色可以自定义
struct CustomBackgroundView: View {
    /// 环境变量 统一控制
    @EnvironmentObject var popObserve: CustomPopObserver
    
    var bgColor: Color = .black.opacity(0.3)
    /// 记录键盘状态
    @State var isShowKeyBoard = false
    
    var body: some View {
        bgColor
            .edgesIgnoringSafeArea(.all)
            .transition(.opacity)
            //对View extension 下面有具体实现
            .keyBoardObserver({ noti, status in
                self.isShowKeyBoard = status
            })
            .onTapGesture {
                if self.isShowKeyBoard {
                    self.hideKeyBoard()
                }else {
                    //自己消失
                    popObserve.backgroundTapPublisher.send()
                }
            }
    }
}
2.自定义ObservableObject 记录当前弹窗的状态
import Combine
​
/// 定义多个弹窗类型,
enum CustomPopType: Int {
    case popView1 = 1
    case popView2
    case popView3
    case popView4
    case popView5
    case popView6
    case popView7
}
​
class CustomPopObserver: ObservableObject {
    
    /// 点击背景是否需要隐藏
    private var isHiddenWhenTapBackground = true
    /// 设置背景颜色
    var backgroundColor = Color.black.opacity(0.3)
    
    /// 展示第一个视图
    var popType: CustomPopType = .popView1 {
        didSet {
            self.isShowShowPopView = true
        }
    }
    
    ///隐藏展示弹窗控制标识,手动来机型send操作,添加变化的animation
    var isShowShowPopView = false {
        didSet{
            withAnimation(.spring()) {
                self.objectWillChange.send()
            }
        }
    }
    
    /// 点击后面背景触发事件
    let backgroundTapPublisher = ObservableObjectPublisher()
    
    private var cancellabelSet = Set<AnyCancellable>()
    
    init() {
        //订阅backgroundTapPublisher,当有事件触发的时候会进行调用
        backgroundTapPublisher.sink { _ in
            if self.isHiddenWhenTapBackground {
                /// 设置所有状态值为false,隐藏弹窗
                self.isShowShowPopView = false                    
            }
        }
        .store(in: &cancellabelSet)
    }
​
    func clear() {
        cancellabelSet.forEach { cancellable in
            cancellable.cancel()
        }
        cancellabelSet.removeAll()
    }
    
    deinit {
        clear()
    }
}
​
3.设置ViewModifier

swiftUIViewmodifie 入参是view,并且返回值也是view,我们利用这一特性来做弹窗。自定义ViewModifier,需要实现ViewModifier协议,而在ViewModifier协议方法中我们可以拿到当前的content,对当前的content在做一层ZStack包装。在自定义的ViewModifier中,还是用了@ViewBuilder来修饰传参,这样可以在ViewBuilder中传入多个View,最后在通过ZStack包裹。下面的处理中其实还对customView进行了一层包装,在实现动画的时候能够做到顶部和底部弹入、弹出的过度。

import SwiftUIstruct CustomPopViewModifier<T : View>: ViewModifier {
    /// 环境变量,只要有变化就会重绘依赖的view
    @EnvironmentObject var showData: CustomPopObserver
    /// 定义属性的时候不能设置为 some View 只能用一个泛型先替换
    private var customView: T
    /// 展示的时候执行的动画
    var transition: AnyTransition
    /// 弹窗的背景色
    var bgColor: Color
​
    ///使用ViewBuilder 可以传入多个View 进行动态设置
    init(bgColor: Color = .black.opacity(0.3),
         transition:AnyTransition = .bottomStyle,
         @ViewBuilder content:() -> T){
        self.bgColor = bgColor
        self.transition = transition
        self.customView = content()
    }
    
    func body(content: Content) -> some View {
        ZStack{
            //上层的View,在包装的时放到最下层
            content.zIndex(0)
            //添加背景
            if showData.isShowShowPopView {
                //添加背景图片
                CustomBackgroundView(bgColor: bgColor).transition(.opacity).zIndex(1)
                /// 包裹一层的原因: 在执行动画的时候 从下到现在 在消失是从.bottom 到 .bottom ,不设置为全屏幕的大小不会从边缘进行动画,包裹的这一层是全屏幕大小
                Rectangle()
                    .fill(.clear)
                    .overlay(content: {
                        self.customView
                    })
                    .frame(width: kScreenWidth,height: kScreenHeight)
                    .ignoresSafeArea()
                    .transition(transition)
                    .zIndex(2)
            }
        }.ignoresSafeArea(.keyboard)
    }
}
​

接下来 我们做一下其他的准备工作,设置动画方式。通过对AnyTransition添加一些默认实现。

extension AnyTransition {
    
    static let popupBackgroundStyle = AnyTransition.opacity
    
    static let bottomStyle = AnyTransition.slideToEdge(insertion: .bottom, removal: .bottom)
    
    static let topStyle = AnyTransition.slideToEdge(insertion: .top, removal: .top)
    
    static let leadingStyle = AnyTransition.slideToEdge(insertion: .leading,removal: .leading)
    
    static let trailingStyle = AnyTransition.slideToEdge(insertion: .trailing,removal: .trailing)
    
    static let leadingAndTrailingStyle = AnyTransition.slideToEdge(insertion: .leading,removal:.trailing)
    
    static let trailingAndLeadingStyle = AnyTransition.slideToEdge(insertion: .trailing,removal:.leading)
    
    static let topAndBottomStyle = AnyTransition.slideToEdge(insertion: .top,removal: .bottom)
    
    static let bottomAndTopStyle = AnyTransition.slideToEdge(insertion: .bottom,removal: .top)
    
    private enum MoveDirection {
        case leading
        case trailing
        case top
        case bottom
    }
    
    private static func slideToEdge(
        insertion: MoveDirection? = .leading,
        removal: MoveDirection? = .trailing
    ) -> AnyTransition {
        return AnyTransition.asymmetric(
            insertion: createMoveTransition(insertion!),
            removal: createMoveTransition(removal!)
        )
    }
​
    private static func createMoveTransition(
        _ direction: MoveDirection
    ) -> AnyTransition {
        switch direction {
            case .leading:  return AnyTransition.move(edge: .leading)
            case .trailing: return AnyTransition.move(edge: .trailing)
            case .top:      return AnyTransition.move(edge: .top)
            case .bottom:   return AnyTransition.move(edge: .bottom)
        }
    }
}
​
extension AnyTransition {
    static var fly: AnyTransition {
        AnyTransition.modifier(active: FlyModifier(pct: 0), identity: FlyModifier(pct: 1))
    }
}
​
struct FlyModifier: GeometryEffect {
    var pct: Double
    
    var animatableData: Double {
        get {
            pct
        }
        set {
            pct = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let a = CGFloat(Angle(degrees: 90 * (1 - pct)).radians)
        
        var transform3d = CATransform3DIdentity
        transform3d.m34 = -1 / max(size.width, size.height)
        
        transform3d = CATransform3DRotate(transform3d, a, 1, 0, 0)
        transform3d = CATransform3DTranslate(transform3d, -size.width / 2.0, -size.width / 2.0, 0)
        
        let afffineTransform1 = ProjectionTransform(CGAffineTransform(translationX: size.width / 2.0, y: size.width / 2.0))
        let afffineTransform2 = ProjectionTransform(CGAffineTransform(scaleX: CGFloat(pct * 2), y: CGFloat(pct * 2)))
        
        if pct <= 0.5 {
            return ProjectionTransform(transform3d).concatenating(afffineTransform2).concatenating(afffineTransform1)
        } else {
            return ProjectionTransform(transform3d).concatenating(afffineTransform1)
        }
    }
}

我们看下弹窗效果:

swiftUI_popView.gif

对于内部弹窗的实现,可以自定义各种样式,接下来是一个自定义的Page,结合ObservableObjectViewModifier

import SwiftUI
​
struct CustomPopPage: View {
    
    @ObservedObject private var popObserve = CustomPopObserver()
  
    let gradientList: [Gradient] = [.red,.orange,.yellow,.orange,.blue,.indigo,.purple]
​
    var body: some View {
        VStack{
            Spacer()
            ForEach(0..<7){ index in
                Button("展示弹窗(index + 1)"){
                    popViewAction(index+1)
                }
                .frame(width: 200,height: 40)
                .foregroundColor(.white)
                .background(LinearGradient(gradient: gradientList[index], startPoint: .top, endPoint: .bottom))
                .cornerRadius(10)
                .padding(.top,5)
            }
            Spacer()
        }
        .modifier(CustomPopViewModifier(transition: getTransitionWithIndex(popObserve.popType),content: {
            switch popObserve.popType {
            case .popView1:
                CustomPopView1()
            case .popView2:
                CustomPopView2()
            case .popView3:
                CustomPopView3()
            case .popView4:
                CustomPopView4()
            case .popView5:
                CustomPopView1()
            case .popView6:
                CustomPopView2()
            case .popView7:
                CustomPopView1()
            }
        }))
        .environmentObject(popObserve)
    }
    
    func popViewAction(_ idx: Int)  {
        guard let type = CustomPopType(rawValue: idx) else {
            return
        }
        popObserve.popType = type
    }
    
    func getTransitionWithIndex(_ index: CustomPopType) -> AnyTransition {
        switch index.rawValue {
        case 1:
            return .topStyle
        case 2:
            return .bottomStyle
        case 3:
            return .topAndBottomStyle
        case 4:
            return .bottomAndTopStyle
        case 5:
            return .leadingStyle
        case 6:
            return .leadingAndTrailingStyle
        case 7:
            return .trailingStyle
        default:
            return .bottomStyle
        }
    }
}

这样操作下来 我们就能实现上图中的弹窗操作,但是还有一些问题存在。

问题

我们App在设计的时候 UI往往是比较复杂 最外层是 NavigationViewTabView,这时候弹窗并不会把导航栏进行覆盖,弹窗的层级比导航栏还要低(最外层的View层级是最低的)。点击返回按钮会直接返回到上一层,弹窗会在dissmiss的时候消失掉。而不是按照我们的想法 先把弹窗消失掉,再次点击返回到上一个界面。

这时候弹窗有其他背景色会明显和导航栏分离,而且由于A界面在push到B界面的时候,导航栏是从A界面传过来的,B界面的弹窗并不能把导航栏包裹在内,就会造成视图UI层级上的分离。

ZStack的弹窗方式适用范围:没有导航栏或导航栏隐藏,没有背景色的弹窗,或者是全局的系统弹窗 可以在App的最外层最一次包装

2.使用系统的模态弹窗跳转实现

  • sheetfullscreenCover : 模态常用,基本不会用到弹窗中,功能有局限性。
  • alertalertSheet:系统自带,不支持自定义。在项目中用到的不多,满足不了自定义UI的需求。

该方式可自定义功能不多,都是从底部弹出,后面view缩小的一个动画,而且动画不可修改,局限性太高。简略测试代码如下,

struct TestView: View {
    @State var state = false
    @State var isSheet = false
    
    var body: some View {
        NavigationView {
            Color.white.edgesIgnoringSafeArea(.all)
                .overlay(content: {
                    VStack{
                        Button("全屏弹窗") {
                            state = true
                        }
                        .foregroundColor(.white)
                        .frame(width: 100,height: 35)
                        .background(.purple)
                        .cornerRadius(10)
                        .padding(.bottom,10)
                        
                        Button("半屏弹窗") {
                            isSheet = true
                        }
                        .foregroundColor(.white)
                        .frame(width: 100,height: 35)
                        .background(.purple)
                        .cornerRadius(10)
                        
                    }
                })
                .fullScreenCover(isPresented: $state) {
                    ZStack{
                        Color.blue.opacity(0.3).contentShape(Rectangle())
                            .onTapGesture {
                                state = false
                            }
                        
                        Text("这是全屏弹窗").foregroundColor(.red).font(.system(size: 18))
                    }.ignoresSafeArea()
            }
            .sheet(isPresented: $isSheet) {
                ZStack{
                    Color.blue.opacity(0.3).contentShape(Rectangle())
                        .onTapGesture {
                            isSheet = false
                        }
                    
                    Text("这是半屏弹窗").foregroundColor(.red).font(.system(size: 18))
                }.ignoresSafeArea()
            }
        }.navigationTitle(Text("swiftUI"))
    }
}
​

效果如下

swiftUI_pop_sheet.gif

上面的方式适合一些自定义弹窗,只适用底部弹出

3.获取主控制器 自定义模态跳转

我们可以使用在UIKit中的方式,获取当前的控制器,使用present的方式弹出自定义视图。我们可以利用swiftUI中的环境变量,每次取值的时候都获取当前ViewController ,使用ViewController去present出我们自定义的弹窗。

1.自定义environmentKeyvalue
struct ViewControllerHolder {
    weak var value: UIViewController?
}
​
struct ViewControllerKey: EnvironmentKey {
    
    static var defaultValue: ViewControllerHolder {
        ViewControllerHolder(value: UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.rootViewController)
    }
}
​
extension EnvironmentValues {
    var viewController: UIViewController? {
        get{ self[ViewControllerKey.self].value }
        set{ self[ViewControllerKey.self].value = newValue }
    }
}
2.设置UIViewController拓展方法
extension UIViewController {
    func present<Content: View>(@ViewBuilder content:() -> Content) {
        let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
        toPresent.modalPresentationStyle = .overCurrentContext
        toPresent.modalTransitionStyle = .crossDissolve
        toPresent.view.backgroundColor = .clear
        toPresent.rootView = AnyView(content().environment(.viewController, toPresent))
        
        present(toPresent, animated: false)
    }
}
3.在需要弹窗的地方引入环境变量
///自定义弹窗
struct PresentViewTest: View {
    /// 引入环境变量
    @Environment(.viewController) private var vcHolder
    
    var body: some View {
        NavigationView {
            Color.blue.opacity(0.3)
                .edgesIgnoringSafeArea(.all)
                .overlay {
                    Button("弹窗展示") {
                        vcHolder?.present(content: {
                            PresentView1()
                        })
                    }
                    .frame(width: 200,height: 35)
                    .background(.purple)
                    .foregroundColor(.white)
                    .cornerRadius(10)
                }
            
        }.navigationTitle("presentTest")
    }
}
​
struct PresentView1: View {
    /// 引入环境变量
    @Environment(.viewController) private var vcHolder
    
    @State var isShow = false
    
    var body: some View {
        ZStack{
            Color.black.opacity(0.3).edgesIgnoringSafeArea(.all).onTapGesture {
                withAnimation {
                    isShow = false
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2){
                    vcHolder?.dismiss(animated: true)
                }
            }
            
            Text("我是present界面的弹窗")
                .font(.system(size: 14))
                .padding(15)
                .frame(width: 300,height: 200 )
                .foregroundColor(.black)
                .background(.yellow)
                .cornerRadius(10)
                .scaleEffect(isShow ? 1 : 0)
                .rotationEffect(.degrees(isShow ? 360 : 0))
                .opacity(isShow ? 1: 0)
        }
        .onAppear {
            withAnimation(.easeIn(duration: 0.2)) {
                isShow = true
            }
        }
    }
}
​

上面例子执行效果如下

swiftUI_present_test.gif

以上就是三种弹窗的实现和效果。

总结

  • 使用ZStack 会有层级关系,当App结构复杂时 覆盖不了NavigationViewTabbarView,适用于toast和一些无背景的弹窗
  • 系统弹窗:alert或者sheet 做一些系统级的弹窗比较合适,自定义不推荐
  • 自定义模态跳转:适合在各个界面做弹窗处理,需要在各个界面引入环境变量,present和dismiss弹窗,而且present执行有动画,各个弹窗的时机衔接有点长。

以上就是弹窗相关的内容,如有不对之处 欢迎指正。如果有其他方式 欢迎沟通。