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)) } 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 { $toasts.delete(toast.id) } else { 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 } } } 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
| @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) } }
|