Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/87 global banner update #88

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions Common/Sources/Common/NotificationBannerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationBannerModel>, action: @escaping (UUID) -> Void) {
self._model = model
self.buttonActionWithId = action
self.buttonAction = nil
}

public init(model: Binding<NotificationBannerModel>, action: @escaping () -> Void) {
self._model = model
self.buttonAction = action
self.buttonActionWithId = nil
}

// MARK: - View
Expand All @@ -41,7 +49,6 @@ public struct NotificationBannerView: View {

Spacer()
}
.padding(12)
}

var content: some View {
Expand All @@ -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)
}
Expand Down
149 changes: 135 additions & 14 deletions Flows/MainFlow/Sources/MainFlow/GlobalBannerModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
}
}
}
}
Expand All @@ -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
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ public final class GlobalBannerStore: ObservableObject {
public func hideAllBanners() {
notifications = []
}

public func hideBanner(with id: UUID) {
notifications.removeAll { $0.id == id }
}

}