自定义弹窗
在UIKit中 自定义弹窗方式很多,比如: 模态ViewController、view覆盖、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
swiftUI中 View的 modifie 入参是view,并且返回值也是view,我们利用这一特性来做弹窗。自定义ViewModifier,需要实现ViewModifier协议,而在ViewModifier协议方法中我们可以拿到当前的content,对当前的content在做一层ZStack包装。在自定义的ViewModifier中,还是用了@ViewBuilder来修饰传参,这样可以在ViewBuilder中传入多个View,最后在通过ZStack包裹。下面的处理中其实还对customView进行了一层包装,在实现动画的时候能够做到顶部和底部弹入、弹出的过度。
import SwiftUI
struct 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)
}
}
}
我们看下弹窗效果:
对于内部弹窗的实现,可以自定义各种样式,接下来是一个自定义的Page,结合ObservableObject和ViewModifier,
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往往是比较复杂 最外层是 NavigationView或TabView,这时候弹窗并不会把导航栏进行覆盖,弹窗的层级比导航栏还要低(最外层的View层级是最低的)。点击返回按钮会直接返回到上一层,弹窗会在dissmiss的时候消失掉。而不是按照我们的想法 先把弹窗消失掉,再次点击返回到上一个界面。
这时候弹窗有其他背景色会明显和导航栏分离,而且由于A界面在push到B界面的时候,导航栏是从A界面传过来的,B界面的弹窗并不能把导航栏包裹在内,就会造成视图UI层级上的分离。
ZStack的弹窗方式适用范围:没有导航栏或导航栏隐藏,没有背景色的弹窗,或者是全局的系统弹窗 可以在App的最外层最一次包装
2.使用系统的模态弹窗跳转实现
sheet,fullscreenCover: 模态常用,基本不会用到弹窗中,功能有局限性。alert,alertSheet:系统自带,不支持自定义。在项目中用到的不多,满足不了自定义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"))
}
}
效果如下
上面的方式适合一些自定义弹窗,只适用底部弹出
3.获取主控制器 自定义模态跳转
我们可以使用在UIKit中的方式,获取当前的控制器,使用present的方式弹出自定义视图。我们可以利用swiftUI中的环境变量,每次取值的时候都获取当前ViewController ,使用ViewController去present出我们自定义的弹窗。
1.自定义environmentKey和value
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
}
}
}
}
上面例子执行效果如下
以上就是三种弹窗的实现和效果。
总结
- 使用
ZStack会有层级关系,当App结构复杂时 覆盖不了NavigationView和TabbarView,适用于toast和一些无背景的弹窗 - 系统弹窗:
alert或者sheet做一些系统级的弹窗比较合适,自定义不推荐 - 自定义模态跳转:适合在各个界面做弹窗处理,需要在各个界面引入环境变量,present和dismiss弹窗,而且present执行有动画,各个弹窗的时机衔接有点长。
以上就是弹窗相关的内容,如有不对之处 欢迎指正。如果有其他方式 欢迎沟通。