From d8046c0c4651cb41ee48a1c478d8a937b2a588a2 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 7 Apr 2021 16:08:28 +0300 Subject: [PATCH 1/2] Added notification banner list for iPad --- .../Common/NotificationBannerModel.swift | 9 +- .../NotificationBannerView.swift | 17 +++- .../MainFlow/GlobalBannerModifier.swift | 87 ++++++++++++++++--- .../GlobalBannerStore/GlobalBannerStore.swift | 5 ++ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/Common/Sources/Common/NotificationBannerModel.swift b/Common/Sources/Common/NotificationBannerModel.swift index f0bee69..53a733b 100644 --- a/Common/Sources/Common/NotificationBannerModel.swift +++ b/Common/Sources/Common/NotificationBannerModel.swift @@ -14,19 +14,24 @@ public enum NotificationBannerStyle { case error } -public struct NotificationBannerModel { +public struct NotificationBannerModel: Identifiable { // MARK: - Properties + public let id: UUID public let title: String public let description: String? public let style: NotificationBannerStyle + public let isAutoHidden: Bool // MARK: - Initialization - public init(title: String, description: String? = nil, style: NotificationBannerStyle) { + public init(title: String, description: String? = nil, style: NotificationBannerStyle, isAutoHidden: Bool = true) { + self.id = UUID() self.title = title self.description = description self.style = style + self.isAutoHidden = isAutoHidden } + } 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..6958898 100644 --- a/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift +++ b/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift @@ -16,7 +16,8 @@ public struct GlobalBannerModifier: ViewModifier { // MARK: - States @Store var store: GlobalBannerStore - + @State var height: CGFloat = 0 + // MARK: - Views public func body(content: Content) -> some View { @@ -27,11 +28,6 @@ public struct GlobalBannerModifier: ViewModifier { notificationList .animation(.easeInOut) .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - closeNotificationBanner() - } - } } } } @@ -42,24 +38,71 @@ 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) + .onAppear { + if model.isAutoHidden { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + closeAllBanners() + } + } + } + } + + func padNotificationList(_ models: [GlobalBanner.Model]) -> some View { + HStack(alignment: .top) { + Spacer() + VStack { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(models) { model in + NotificationBannerView(model: .constant(model), action: closeBunner) + .frame(width: 393) + .padding(EdgeInsets.horizontal(12)) + .background(HeightPreferenceKeyReader()) + .onAppear { + if !model.isAutoHidden { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + closeAllBanners() + } + } + } + } + .padding(.top, 12) + Spacer() + } } + .frame(height: height) + .padding(0) + Spacer() } + .onPreferenceChange(HeightPreferenceKey.self) { height = $0 + CGFloat(store.notifications.count) * 8 + 12 } } } // 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 @@ -70,3 +113,23 @@ public extension 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 } + } + } From 395030a2ec4e6ca3344708de99747cbddf53d218 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 7 Apr 2021 17:33:20 +0300 Subject: [PATCH 2/2] Added drag gesture for banners --- .../Common/NotificationBannerModel.swift | 13 ++- .../MainFlow/GlobalBannerModifier.swift | 90 +++++++++++++++---- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/Common/Sources/Common/NotificationBannerModel.swift b/Common/Sources/Common/NotificationBannerModel.swift index 53a733b..bdafa93 100644 --- a/Common/Sources/Common/NotificationBannerModel.swift +++ b/Common/Sources/Common/NotificationBannerModel.swift @@ -16,22 +16,29 @@ public enum NotificationBannerStyle { 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 isAutoHidden: Bool + public let autoHide: AutoHide // MARK: - Initialization - public init(title: String, description: String? = nil, style: NotificationBannerStyle, isAutoHidden: Bool = true) { + 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.isAutoHidden = isAutoHidden + self.autoHide = autoHide } } diff --git a/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift b/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift index 6958898..25537a3 100644 --- a/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift +++ b/Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift @@ -17,6 +17,8 @@ public struct GlobalBannerModifier: ViewModifier { @Store var store: GlobalBannerStore @State var height: CGFloat = 0 + @State var offset: CGFloat = 0 + @State var activeId: UUID? // MARK: - Views @@ -26,8 +28,6 @@ public struct GlobalBannerModifier: ViewModifier { if !store.notifications.isEmpty { notificationList - .animation(.easeInOut) - .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) } } } @@ -44,18 +44,45 @@ public struct GlobalBannerModifier: ViewModifier { } } } - + // 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 { - if model.isAutoHidden { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + 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 { @@ -65,17 +92,7 @@ public struct GlobalBannerModifier: ViewModifier { ScrollView(showsIndicators: false) { VStack(spacing: 0) { ForEach(models) { model in - NotificationBannerView(model: .constant(model), action: closeBunner) - .frame(width: 393) - .padding(EdgeInsets.horizontal(12)) - .background(HeightPreferenceKeyReader()) - .onAppear { - if !model.isAutoHidden { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - closeAllBanners() - } - } - } + padNotificationBannerView(model) } .padding(.top, 12) Spacer() @@ -89,6 +106,46 @@ public struct GlobalBannerModifier: ViewModifier { } } + 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 closeAllBanners() { @@ -112,6 +169,7 @@ public extension View { func globalBanner() -> some View { modifier(GlobalBannerModifier()) } + } // MARK: - Fileprivate Preference Keys