diff --git a/Common/Sources/Common/NotificationBannerModel.swift b/Common/Sources/Common/NotificationBannerModel.swift index f0bee69..bdafa93 100644 --- a/Common/Sources/Common/NotificationBannerModel.swift +++ b/Common/Sources/Common/NotificationBannerModel.swift @@ -14,19 +14,31 @@ public enum NotificationBannerStyle { case error } -public struct NotificationBannerModel { +public struct NotificationBannerModel: Identifiable { + + // MARK: - Nested Types + + public enum AutoHide { + case active(after: Double) + case inactive + } // MARK: - Properties + public let id: UUID public let title: String public let description: String? public let style: NotificationBannerStyle + public let autoHide: AutoHide // MARK: - Initialization - public init(title: String, description: String? = nil, style: NotificationBannerStyle) { + public init(title: String, description: String? = nil, style: NotificationBannerStyle, autoHide: AutoHide = .active(after: 3)) { + self.id = UUID() self.title = title self.description = description self.style = style + self.autoHide = autoHide } + } diff --git a/Components/Sources/Components/NotificationBanner/NotificationBannerView.swift b/Components/Sources/Components/NotificationBanner/NotificationBannerView.swift index a241470..d502fdb 100644 --- a/Components/Sources/Components/NotificationBanner/NotificationBannerView.swift +++ b/Components/Sources/Components/NotificationBanner/NotificationBannerView.swift @@ -19,13 +19,21 @@ public struct NotificationBannerView: View { // MARK: - Properties - let buttonAction: () -> Void + let buttonActionWithId: ((UUID) -> Void)? + let buttonAction: (() -> Void)? // MARK: - Initialization + public init(model: Binding, action: @escaping (UUID) -> Void) { + self._model = model + self.buttonActionWithId = action + self.buttonAction = nil + } + public init(model: Binding, action: @escaping () -> Void) { self._model = model self.buttonAction = action + self.buttonActionWithId = nil } // MARK: - View @@ -41,7 +49,6 @@ public struct NotificationBannerView: View { Spacer() } - .padding(12) } var content: some View { @@ -55,7 +62,11 @@ public struct NotificationBannerView: View { Spacer() - Button(action: buttonAction) { + Button { + buttonActionWithId?(model.id) + buttonAction?() + } + label: { Icons.xmark.image .foregroundColor(model.style.iconColor) } diff --git a/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift b/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift index 27f5551..25537a3 100644 --- a/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift +++ b/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift @@ -16,7 +16,10 @@ public struct GlobalBannerModifier: ViewModifier { // MARK: - States @Store var store: GlobalBannerStore - + @State var height: CGFloat = 0 + @State var offset: CGFloat = 0 + @State var activeId: UUID? + // MARK: - Views public func body(content: Content) -> some View { @@ -25,13 +28,6 @@ public struct GlobalBannerModifier: ViewModifier { if !store.notifications.isEmpty { notificationList - .animation(.easeInOut) - .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - closeNotificationBanner() - } - } } } } @@ -42,24 +38,128 @@ public struct GlobalBannerModifier: ViewModifier { var notificationList: some View { if let model = store.notifications.last { if UIDevice.current.userInterfaceIdiom.isPhone { - NotificationBannerView(model: .constant(model), action: closeNotificationBanner) + phoneNotificationList(model) } else { - HStack { - Spacer() - NotificationBannerView(model: .constant(model), action: closeNotificationBanner) - .frame(width: 393) + padNotificationList(store.notifications.reversed()) + } + } + } + + // MARK: - View Methods + + func phoneNotificationList(_ model: GlobalBanner.Model) -> some View { + NotificationBannerView(model: .constant(model), action: closeAllBanners) + .animation(.easeInOut) + .padding(.horizontal, 12) + .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) + .offset(x: 0, y: model.id == activeId ? offset : 0) + .onAppear { + switch model.autoHide { + case let .active(after: time): + DispatchQueue.main.asyncAfter(deadline: .now() + time) { + closeAllBanners() + } + case .inactive: + break } } + .gesture(DragGesture() + .onChanged { value in + let horizontalAmount = value.translation.width as CGFloat + let verticalAmount = value.translation.height as CGFloat + + if abs(verticalAmount) > abs(horizontalAmount), verticalAmount < 0 { + activeId = model.id + offset = verticalAmount + } else { + activeId = nil + } + } + .onEnded { value in + let horizontalAmount = value.translation.width as CGFloat + let verticalAmount = value.translation.height as CGFloat + + if abs(verticalAmount) > abs(horizontalAmount), verticalAmount < -30 { + closeAllBanners() + } + }) + } + + func padNotificationList(_ models: [GlobalBanner.Model]) -> some View { + HStack(alignment: .top) { + Spacer() + VStack { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(models) { model in + padNotificationBannerView(model) + } + .padding(.top, 12) + Spacer() + } + } + .frame(height: height) + .padding(0) + Spacer() + } + .onPreferenceChange(HeightPreferenceKey.self) { height = $0 + CGFloat(store.notifications.count) * 8 + 12 } } } + func padNotificationBannerView(_ model: GlobalBanner.Model) -> some View { + NotificationBannerView(model: .constant(model), action: closeBunner) + .frame(width: 393) + .padding(EdgeInsets.horizontal(12)) + .background(HeightPreferenceKeyReader()) + .animation(.easeInOut) + .transition(.asymmetric(insertion: .move(edge: .top), removal: .move(edge: .trailing))) + .offset(x: model.id == activeId ? offset : 0, y: 0) + .onAppear { + switch model.autoHide { + case let .active(after: time): + DispatchQueue.main.asyncAfter(deadline: .now() + time) { + closeBunner(with: model.id) + } + case .inactive: + break + } + } + .gesture(DragGesture() + .onChanged { value in + let horizontalAmount = value.translation.width as CGFloat + let verticalAmount = value.translation.height as CGFloat + + if abs(horizontalAmount) > abs(verticalAmount), horizontalAmount > 0 { + activeId = model.id + offset = horizontalAmount + } else { + activeId = nil + } + } + .onEnded { value in + let horizontalAmount = value.translation.width as CGFloat + let verticalAmount = value.translation.height as CGFloat + + if abs(horizontalAmount) > abs(verticalAmount), horizontalAmount > 40 { + closeBunner(with: model.id) + } + }) + } + // MARK: - Private Methods - private func closeNotificationBanner() { + private func closeAllBanners() { withAnimation { store.hideAllBanners() } } + + private func closeBunner(with id: UUID) { + withAnimation { + store.hideBanner(with: id) + } + } + } // MARK: - Extensions @@ -69,4 +169,25 @@ public extension View { func globalBanner() -> some View { modifier(GlobalBannerModifier()) } + +} + +// MARK: - Fileprivate Preference Keys + +fileprivate struct HeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = value + nextValue() + } +} + +// MARK: - Fileprivate Views + +fileprivate struct HeightPreferenceKeyReader: View { + var body: some View { + GeometryReader { geometryProxy in + Color.clear.preference(key: HeightPreferenceKey.self, value: geometryProxy.size.height) + } + } } diff --git a/Stores/GlobalBannerStore/Sources/GlobalBannerStore/GlobalBannerStore.swift b/Stores/GlobalBannerStore/Sources/GlobalBannerStore/GlobalBannerStore.swift index 604f6f9..0ecc286 100644 --- a/Stores/GlobalBannerStore/Sources/GlobalBannerStore/GlobalBannerStore.swift +++ b/Stores/GlobalBannerStore/Sources/GlobalBannerStore/GlobalBannerStore.swift @@ -37,4 +37,9 @@ public final class GlobalBannerStore: ObservableObject { public func hideAllBanners() { notifications = [] } + + public func hideBanner(with id: UUID) { + notifications.removeAll { $0.id == id } + } + }