扬庆の博客

SwiftUI-实现弹出提示框模拟AirPods连接

字数统计: 765阅读时长: 4 min
2024/11/04 Share

SwiftUI实现底部弹出提示框。swiftUI, Animation, Layout

功能: 点击按钮弹出Toast, 堆叠式, 点击可全屏展示, 左侧滑可移除, 移除全部退出全屏。

剖析:

toast显示在底部修饰符: .overlay(alignment: .bottom)

isExpanded: 弹出一个toastsView, 该视图内部通过isExpanded属性,可控制layout (v轴,z轴) 切换布局.
isDeleting: 侧滑移除toastsView的item.

layout 内部逻辑
layout 内部布局每个 item toast展示样式.

  • modifier: Gesture 手势, DragGesture(拖拽), 可控制侧滑, 拿到xOffset, 逻辑判断移除(赋值isDeleting).

  • modifier:.offset(x: xOffset) 可实现侧滑.
    modifer: .visualEffect 对每个item实现scale, offset 操作.

  • modifier: .zindex() 控制删除时从底部移除.
    modifier: .transition(.asymetric) 使用复合转换动画, 丛edge: .leading 左侧移除.

代码

Per toast所需信息

1
2
3
4
5
6
7
8
9
10
11
12
struct Toast: Identifiable {
private(set) var id: String = UUID().uuidString
var content: AnyView

init(content: @escaping (String)-> some View) {
self.content = .init(content(id))
}

/// View Properties
var offsetX: CGFloat = 0
var isDeleting: Bool = false
}

toastsView 作用的上层任意视图

1
2
3
4
5
6
7
8
9
10
extension View {
@ViewBuilder
func interactiveToasts(_ toasts: Binding<[Toast]>) -> some View {
self
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
ToastsView(toasts: toasts)
}
}
}

组合视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
fileprivate struct ToastsView: View {
@Binding var toasts: [Toast]
@State private var isExpanded: Bool = false

var body: some View {
ZStack(alignment: .bottom) {
if isExpanded {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
.onTapGesture {
isExpanded = false
}
}

let layout = isExpanded ? AnyLayout(VStackLayout(spacing: 10)) : AnyLayout(ZStackLayout())

layout {
ForEach($toasts) { $toast in
let index = (toasts.count - 1) - (toasts.firstIndex(where: { $0.id == toast.id}) ?? 0)

toast.content
.offset(x: toast.offsetX) // 向左移动删除手势。
.gesture(
DragGesture()
.onChanged { value in
let xOffset = value.translation.width < 0 ? value.translation.width : 0
toast.offsetX = xOffset
}
.onEnded { value in
let xOffset = (value.velocity.width / 2) + value.translation.width

if -xOffset > 200 {
/// Remove Toast
$toasts.delete(toast.id)
} else {
/// Reset Toast to initial position
toast.offsetX = 0
}
}
)
.visualEffect { [isExpanded] content, proxy in
content
.scaleEffect(isExpanded ? 1 : scale(index), anchor: .bottom)
.offset(y: isExpanded ? 0 : offsetY(index))
}
.zIndex(toast.isDeleting ? 1000 : 0) // 删除的时候放下面
.frame(maxWidth: .infinity)
.transition(.asymmetric(insertion: .offset(y: 100), removal: .move(edge: .leading)))
}
}
.onTapGesture {
isExpanded.toggle()
}
.padding(.bottom, 15)
}
.animation(.bouncy, value: isExpanded)
.onChange(of: toasts.isEmpty) { oldValue, newValue in
if newValue {
isExpanded = false
}
}
}

/* 0 15 30 这三个区别*/
nonisolated func offsetY(_ index: Int) -> CGFloat {
let offset = min(CGFloat(index) * 15, 30)
return -offset
}

nonisolated func scale(_ index: Int) -> CGFloat {
let scale = min(CGFloat(index) * 0.1, 1)
return 1 - scale
}
}

extension Binding<[Toast]> {
func delete(_ id: String) {
if let toast = first(where: { $0.id == id }) {
toast.wrappedValue.isDeleting = true
}
withAnimation(.bouncy) {
self.wrappedValue.removeAll(where: { $0.id == id })
}
}
}

外部控制添加的每个Toast具体信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 自定义 Toast View
@ViewBuilder
func ToastView(_ id: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "airpods.pro")

Text("iJustine's Airpods")
.font(.callout)
}
.foregroundStyle(Color.primary)
.padding(.vertical, 12)
.padding(.horizontal, 15)
.background {
Capsule()
.fill(.background)
.shadow(color: .black.opacity(0.06), radius: 3, x: -1, y: -3)
.shadow(color: .black.opacity(0.06), radius: 2, x: 1, y: 3)
}
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
****使用者可以是任何视图, 使用扩展的View , 通过.interactiveToasts($toasts)这个ViewBuilder****
var body: some View {
NavigationStack {
List {
Text("Dummy List Row View")
}
.navigationTitle("Toasts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Show") { showToast() }
}
}
}
.interactiveToasts($toasts)
}

func showToast() {
withAnimation(.bouncy) {
let toast = Toast { id in
ToastView(id)
}
toasts.append(toast)
}
}

swiftuiToast

CATALOG
  1. 1. 剖析:
  2. 2. 代码
    1. 2.1. 使用