diff --git a/Sources/Orbit/Components/Toast.swift b/Sources/Orbit/Components/Toast.swift index c2295ab9f2b..df38bece0be 100644 --- a/Sources/Orbit/Components/Toast.swift +++ b/Sources/Orbit/Components/Toast.swift @@ -32,16 +32,19 @@ public struct Toast: View { public var body: some View { if let toast = toastQueue.toast { ToastWrapper( - toast.description, - icon: toast.icon, progress: toast.progress, pauseAction: toastQueue.pause, resumeAction: toastQueue.resume, dismissAction: toastQueue.dismiss - ) + ) { + toast.description + } icon: { + toast.icon + } .id(toast.id) .padding(.xSmall) .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.spring) } } @@ -51,211 +54,24 @@ public struct Toast: View { } } -/// Orbit ``Toast`` with no gesture handling or queue management. -public struct ToastContent: View { - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.textColor) private var textColor - - private let description: String - private let icon: Icon.Symbol? - private let progress: CGFloat - - public var body: some View { - HStack(alignment: .top, spacing: .xSmall) { - Icon(icon) - Text(description) - Spacer(minLength: 0) - } - .textColor(textColor ?? foregroundColor) - .padding(.small) - .contentShape(Rectangle()) - .background(background) - } - - @ViewBuilder private var background: some View { - backgroundColor - .overlay(progressIndicator, alignment: .leading) - .clipShape(shape) - .elevation(.level3, shape: .roundedRectangle()) - } - - @ViewBuilder private var progressIndicator: some View { - GeometryReader { geometry in - progressColor - .opacity(max(0, progress * 2 - 0.5) * 0.3) - .clipShape(shape) - .frame(width: geometry.size.width * progress, alignment: .bottomLeading) - .animation(ToastQueue.animationIn, value: progress) - } - } - - private var foregroundColor: Color { - colorScheme == .light ? .whiteNormal : .inkDark - } - - private var backgroundColor: Color { - colorScheme == .light ? .inkDark : .whiteDarker - } - - private var progressColor: Color { - colorScheme == .light ? .inkNormal : .cloudNormal - } - - private var shape: some Shape { - RoundedRectangle(cornerRadius: BorderRadius.default, style: .continuous) - } - - /// Creates Orbit ``ToastContent`` component with no gesture handling or queue management. - public init(_ description: String, icon: Icon.Symbol? = nil, progress: CGFloat = 0) { - self.description = description - self.icon = icon - self.progress = progress - } -} - -/// Orbit ``ToastWrapper`` component with gesture handling, but no queue management. -public struct ToastWrapper: View { - - static let minOffsetY: CGFloat = -10 - static let maxOffsetY: CGFloat = 10 - - @Environment(\.isHapticsEnabled) private var isHapticsEnabled - - private let description: String - private let icon: Icon.Symbol? - private let progress: CGFloat - private let pauseAction: () -> Void - private let resumeAction: () -> Void - private let dismissAction: () -> Void - - @State private var offsetY: CGFloat = 0 - @State private var gaveFeedback: Bool = false - - public var body: some View { - ToastContent(description, icon: icon, progress: progress) - .opacity(opacity) - .offset(y: cappedOffsetY) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { gesture in - pauseAction() - offsetY = gesture.translation.height / 2 - processDragChanged() - } - .onEnded { _ in - if isOffsetDismiss { - dismissAction() - } else { - resumeAction() - withAnimation(ToastQueue.animationIn) { - offsetY = 0 - } - } - } - ) - } - - private var isOffsetDismiss: Bool { - offsetY < Self.minOffsetY - } - - private var dismissProgress: CGFloat { - min(0, cappedOffsetY) / Self.minOffsetY - } - - private var opacity: CGFloat { - return 1 - dismissProgress * 0.2 - } - - private var cappedOffsetY: CGFloat { - min(Self.maxOffsetY, offsetY) - } - - private func processDragChanged() { - if dismissProgress >= 1, gaveFeedback == false { - if isHapticsEnabled { - HapticsProvider.sendHapticFeedback(.notification(.warning)) - } - - gaveFeedback = true - } - - if dismissProgress == 0 { - gaveFeedback = false - } - } - - /// Creates Orbit ``ToastWrapper`` component. - public init( - _ description: String, - icon: Icon.Symbol? = nil, - progress: CGFloat = 0, - pauseAction: @escaping () -> Void, - resumeAction: @escaping() -> Void, - dismissAction: @escaping () -> Void - ) { - self.description = description - self.icon = icon - self.progress = progress - self.pauseAction = pauseAction - self.resumeAction = resumeAction - self.dismissAction = dismissAction - } - -} - // MARK: - Previews struct ToastPreviews: PreviewProvider { - static let description = "Toast shows a brief message that's clear & understandable." static let toastQueue = ToastQueue() static let toastLiveQueue = ToastQueue() static var previews: some View { - PreviewWrapper { - standalone - standaloneWrapper - progress - } - .padding(.xLarge) - .previewLayout(.sizeThatFits) - PreviewWrapper { interactive } } - static var standalone: some View { - ToastContent(description, icon: .grid) - .previewDisplayName() - - } - - static var standaloneWrapper: some View { - ToastWrapper(description, icon: .checkCircle, progress: 0.6, pauseAction: {}, resumeAction: {}, dismissAction: {}) - .previewDisplayName() - } - - static var progress: some View { - VStack(alignment: .leading, spacing: .xxxLarge) { - ToastContent(description, progress: 0.01) - ToastContent(description, progress: 0.2) - ToastContent(description, progress: 0.8) - ToastContent(description, progress: 1.1) - ToastContent("Toast shows a brief message that's clear & understandable.", icon: .checkCircle, progress: 0.6) - } - .padding(.top, .large) - .padding(.bottom, .xxxLarge) - .previewDisplayName() - } - static var interactive: some View { NavigationView { VStack(alignment: .leading, spacing: .medium) { Spacer() - Button("Add toast 1") { toastLiveQueue.add(description) } + Button("Add toast 1") { toastLiveQueue.add(ToastContentPreviews.description) } Button("Add toast 2") { toastLiveQueue.add("Another toast message.") } } .padding(.medium) @@ -269,9 +85,4 @@ struct ToastPreviews: PreviewProvider { static var toast: some View { Toast(toastQueue: toastLiveQueue) } - - static var snapshot: some View { - progress - .padding(.medium) - } } diff --git a/Sources/Orbit/Support/Toast/ToastContent.swift b/Sources/Orbit/Support/Toast/ToastContent.swift new file mode 100644 index 00000000000..4a154b4a55c --- /dev/null +++ b/Sources/Orbit/Support/Toast/ToastContent.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// Orbit ``Toast`` with no gesture handling or queue management. +public struct ToastContent: View { + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.textColor) private var textColor + + private let progress: CGFloat + @ViewBuilder private let icon: Icon + @ViewBuilder private let description: Description + + public var body: some View { + HStack(alignment: .top, spacing: .xSmall) { + icon + description + Spacer(minLength: 0) + } + .textColor(textColor ?? foregroundColor) + .padding(.small) + .contentShape(Rectangle()) + .background(background) + } + + @ViewBuilder private var background: some View { + backgroundColor + .overlay(progressIndicator, alignment: .leading) + .clipShape(shape) + .elevation(.level3, shape: .roundedRectangle()) + } + + @ViewBuilder private var progressIndicator: some View { + GeometryReader { geometry in + progressColor + .opacity(max(0, progress * 2 - 0.5) * 0.3) + .clipShape(shape) + .frame(width: geometry.size.width * progress, alignment: .bottomLeading) + .animation(ToastQueue.animationIn, value: progress) + } + } + + private var foregroundColor: Color { + colorScheme == .light ? .whiteNormal : .inkDark + } + + private var backgroundColor: Color { + colorScheme == .light ? .inkDark : .whiteDarker + } + + private var progressColor: Color { + colorScheme == .light ? .inkNormal : .cloudNormal + } + + private var shape: some Shape { + RoundedRectangle(cornerRadius: BorderRadius.default, style: .continuous) + } + + /// Creates Orbit ``ToastContent`` component with custom content. + public init( + progress: CGFloat = 0, + @ViewBuilder description: () -> Description, + @ViewBuilder icon: () -> Icon = { EmptyView() } + ) { + self.progress = progress + self.description = description() + self.icon = icon() + } +} + +// MARK: - Convenience Inits +public extension ToastContent where Description == Text, Icon == Orbit.Icon { + + /// Creates Orbit ``ToastContent`` component. + @_disfavoredOverload + init( + _ description: some StringProtocol = String(""), + icon: Icon.Symbol? = nil, + progress: CGFloat = 0 + ) { + self.init(progress: progress) { + Text(description) + } icon: { + Icon(icon) + } + } + + /// Creates Orbit ``ToastContent`` component with localized description. + @_semantics("swiftui.init_with_localization") + init( + _ description: LocalizedStringKey, + icon: Icon.Symbol? = nil, + progress: CGFloat = 0, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.init(progress: progress) { + Text(description, tableName: tableName, bundle: bundle) + } icon: { + Icon(icon) + } + } +} + +// MARK: - Previews +struct ToastContentPreviews: PreviewProvider { + + static let description = "Toast shows a brief message that's clear & understandable." + + static var previews: some View { + PreviewWrapper { + standalone + progress + } + .padding(.xLarge) + .previewLayout(.sizeThatFits) + } + + static var standalone: some View { + ToastContent(description, icon: .grid) + .previewDisplayName() + + } + + static var progress: some View { + VStack(alignment: .leading, spacing: .xxxLarge) { + ToastContent(description, progress: 0.01) + ToastContent(description, progress: 0.2) + ToastContent(description, progress: 0.8) + ToastContent(description, progress: 1.1) + ToastContent("Toast shows a brief message that's clear & understandable.", icon: .checkCircle, progress: 0.6) + } + .padding(.top, .large) + .padding(.bottom, .xxxLarge) + .previewDisplayName() + } + + static var snapshot: some View { + progress + .padding(.medium) + } +} diff --git a/Sources/Orbit/Support/Toast/ToastQueue.swift b/Sources/Orbit/Support/Toast/ToastQueue.swift index 688bbef4dfd..45578742f4b 100644 --- a/Sources/Orbit/Support/Toast/ToastQueue.swift +++ b/Sources/Orbit/Support/Toast/ToastQueue.swift @@ -20,24 +20,65 @@ public final class ToastQueue: ObservableObject { /// View model for Orbit ``Toast`` component. public struct Toast: Identifiable { + public let id: UUID - let description: String - let icon: Icon.Symbol? let progress: Double + + @ViewBuilder let icon: AnyView + @ViewBuilder let description: AnyView - init(id: UUID = UUID(), description: String, icon: Icon.Symbol?, progress: Double) { + init( + id: UUID = UUID(), + progress: Double, + @ViewBuilder description: () -> AnyView, + @ViewBuilder icon: () -> AnyView + ) { self.id = id - self.description = description - self.icon = icon self.progress = progress + self.description = description() + self.icon = icon() } - public init(_ description: String, icon: Icon.Symbol? = nil) { - self.init(description: description, icon: icon, progress: 0) + public init( + @ViewBuilder description: () -> AnyView, + @ViewBuilder icon: () -> AnyView = { AnyView(EmptyView()) } + ) { + self.init(progress: 0, description: description, icon: icon) + } + + @_disfavoredOverload + public init( + _ description: some StringProtocol = String(""), + icon: Icon.Symbol? = nil + ) { + self.init { + AnyView(Text(description)) + } icon: { + AnyView(Icon(icon)) + } + } + + @_semantics("swiftui.init_with_localization") + public init( + _ description: LocalizedStringKey, + icon: Icon.Symbol? = nil, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.init { + AnyView(Text(description, tableName: tableName, bundle: bundle)) + } icon: { + AnyView(Icon(icon)) + } } func withProgress(_ progress: Double) -> Self { - .init(id: id, description: description, icon: icon, progress: max(min(1, progress), 0)) + .init(id: id, progress: max(min(1, progress), 0)) { + description + } icon: { + icon + } } } @@ -62,10 +103,29 @@ public final class ToastQueue: ObservableObject { } /// Add a new Toast to be displayed as soon as there is no active Toast displayed. - public func add(_ desctription: String, icon: Icon.Symbol? = nil) { - add(Toast(desctription, icon: icon)) + @_disfavoredOverload + public func add( + _ description: some StringProtocol = String(""), + icon: Icon.Symbol? = nil + ) { + add( + Toast(description, icon: icon) + ) } - + + /// Add a new Toast to be displayed as soon as there is no active Toast displayed. + public func add( + _ description: LocalizedStringKey, + icon: Icon.Symbol? = nil, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + add( + Toast(description, icon: icon) + ) + } + /// Add a new Toast to be displayed as soon as there is no active Toast displayed. public func add(_ toast: Toast) { toastsSubject.send(toast) diff --git a/Sources/Orbit/Support/Toast/ToastWrapper.swift b/Sources/Orbit/Support/Toast/ToastWrapper.swift new file mode 100644 index 00000000000..19f71b8dc0b --- /dev/null +++ b/Sources/Orbit/Support/Toast/ToastWrapper.swift @@ -0,0 +1,170 @@ +import SwiftUI + +/// Orbit ``ToastWrapper`` component with gesture handling, but no queue management. +public struct ToastWrapper: View { + + @Environment(\.isHapticsEnabled) private var isHapticsEnabled + + private let minOffsetY: CGFloat = -10 + private let maxOffsetY: CGFloat = 10 + private let progress: CGFloat + private let pauseAction: () -> Void + private let resumeAction: () -> Void + private let dismissAction: () -> Void + + @ViewBuilder private let icon: Icon + @ViewBuilder private let description: Description + + @State private var offsetY: CGFloat = 0 + @State private var gaveFeedback: Bool = false + + public var body: some View { + ToastContent(progress: progress) { + description + } icon: { + icon + } + .opacity(opacity) + .offset(y: cappedOffsetY) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + pauseAction() + offsetY = gesture.translation.height / 2 + processDragChanged() + } + .onEnded { _ in + if isOffsetDismiss { + dismissAction() + } else { + resumeAction() + withAnimation(ToastQueue.animationIn) { + offsetY = 0 + } + } + } + ) + } + + private var isOffsetDismiss: Bool { + offsetY < minOffsetY + } + + private var dismissProgress: CGFloat { + min(0, cappedOffsetY) / minOffsetY + } + + private var opacity: CGFloat { + return 1 - dismissProgress * 0.2 + } + + private var cappedOffsetY: CGFloat { + min(maxOffsetY, offsetY) + } + + private func processDragChanged() { + if dismissProgress >= 1, gaveFeedback == false { + if isHapticsEnabled { + HapticsProvider.sendHapticFeedback(.notification(.warning)) + } + + gaveFeedback = true + } + + if dismissProgress == 0 { + gaveFeedback = false + } + } + + /// Creates Orbit ``ToastWrapper`` component with custom content. + public init( + progress: CGFloat = 0, + pauseAction: @escaping () -> Void, + resumeAction: @escaping() -> Void, + dismissAction: @escaping () -> Void, + @ViewBuilder description: () -> Description, + @ViewBuilder icon: () -> Icon = { EmptyView() } + ) { + self.progress = progress + self.pauseAction = pauseAction + self.resumeAction = resumeAction + self.dismissAction = dismissAction + self.description = description() + self.icon = icon() + } +} + +// MARK: - Convenience Inits +public extension ToastWrapper where Description == Text, Icon == Orbit.Icon { + + /// Creates Orbit ``ToastWrapper`` component. + @_disfavoredOverload + init( + _ description: some StringProtocol = String(""), + icon: Icon.Symbol? = nil, + progress: CGFloat = 0, + pauseAction: @escaping () -> Void, + resumeAction: @escaping() -> Void, + dismissAction: @escaping () -> Void + ) { + self.init( + progress: progress, + pauseAction: pauseAction, + resumeAction: resumeAction, + dismissAction: dismissAction + ) { + Text(description) + } icon: { + Icon(icon) + } + } + + /// Creates Orbit ``ToastWrapper`` component with localized description. + @_semantics("swiftui.init_with_localization") + init( + _ description: LocalizedStringKey, + icon: Icon.Symbol? = nil, + progress: CGFloat = 0, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil, + pauseAction: @escaping () -> Void, + resumeAction: @escaping() -> Void, + dismissAction: @escaping () -> Void + ) { + self.init( + progress: progress, + pauseAction: pauseAction, + resumeAction: resumeAction, + dismissAction: dismissAction + ) { + Text(description, tableName: tableName, bundle: bundle) + } icon: { + Icon(icon) + } + } +} + +// MARK: - Previews +struct ToastWrapperPreviews: PreviewProvider { + + static var previews: some View { + PreviewWrapper { + standalone + } + .padding(.xLarge) + .previewLayout(.sizeThatFits) + } + + static var standalone: some View { + ToastWrapper( + ToastContentPreviews.description, + icon: .checkCircle, + progress: 0.6, + pauseAction: {}, + resumeAction: {}, + dismissAction: {} + ) + .previewDisplayName() + } +} diff --git a/Tests/SnapshotTests/Components/ToastTests.swift b/Tests/SnapshotTests/Components/ToastTests.swift index 7d08a53d50d..9f420fe5f74 100644 --- a/Tests/SnapshotTests/Components/ToastTests.swift +++ b/Tests/SnapshotTests/Components/ToastTests.swift @@ -4,6 +4,6 @@ import XCTest class ToastTests: SnapshotTestCase { func testToasts() { - assert(ToastPreviews.snapshot) + assert(ToastContentPreviews.snapshot) } }