From b238c37e0cbcebccaf2e5be4cdaad466624f73f3 Mon Sep 17 00:00:00 2001 From: Kevin <141606011+kevinneko@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:03:52 +0800 Subject: [PATCH] Feat SwiftUI Toast (#245) * Add Tooltip view * Refactor Tooltip style * Can adjust tooltip position * Add tooltip playground for testing * Refine layout tips logic * Refine layout logic * Update CharcoalTooltip.swift * Refine tooltip spacing * Add CharcoalIdentifiableOverlayView * Use Actor to prevent Data Race * Remove CharcoalIdentifiableOverlayView out * Clean code * Only update view when it is isPresenting * Clean access control * Add TooltipsView * Fix tooltipY layout logic * Use main actor and remove CharcoalContainerManagerKey * Fix access control on CharcoalContainerManager * Make viewID as @State * Use EnviromentObject to create CharcoalContainerManager for each container * Use ObservedObject on CharcoalContainerManager * Add use charcoal button as demo trigger * Add arrow logic on tooltip * Refine arrow logic * Refine arrow layout logic * Use StateObject to prevent unexpected reinit * Refactor TooltipBubbleShape * Fix edge layout logic * Add comment * Format code * Use new approach to remove adaptiveMaxWidth * Fix the tip bubble's position latency * Add dismiss when interaction * Reformat * Add initial Snackbar * Add thumbnail image * Add support for thumbnailImage and action * Clean code * Reformat code * Rename ActionContent * Replace thumbnailImage type * Add dismissOnTouchOutside control * Add comment on CharcoalIdentifiableOverlayView * Update CharcoalTooltip.swift * Add SnackBar demo * Replace thumbnail with charcoal logo * Use @ViewBuilder * Clean Code * Made code more readable * Update ToastsView.swift * Add auto dismiss logic * Fix dismiss comment * Add Identifiable to CharcoalIdentifiableOverlayView * Make all CharcoalPopupView identifiable * Move all control logic into CharcoalPopupView * Reformat * Refine CharcoalOverlayContainerChild logic of updating view * Rename to CharcoalOverlayUpdaterContainer * Add CharcoalToast * Refine toast control * Refine screen edge of toast * Refine comments * Rename CharcoalPopupProtocol * Refine isActuallyPresenting logic * Clean animation * Add animation configuration * Add custom animation * Add CharcoalToastProtocol * Makes CharcoalSnackBar adapt CharcoalToastProtocol * Remove time delay * Refine SnackBar Animation logic * Add CharcoalToastAnimationModifier * Reformat code * Update CharcoalPopupViewEdge of direction * Refine demo * Fix missing animation * Rename charcoalAnimatedToast to charcoalAnimatableToast * Rename CharcoalAnimatableToastProtocol * Rename for clean * Simplify protocols * Add drag control * Add Dismiss timer control logic * Refine drag damping logic * Add CharcoalToastDraggable * Use CharcoalToastDraggableModifier on CharcoalSnackBar * Format code * Init CharcoalTooltipView * Init Bubble shape * Refine tooltip preview * Rename as Charcoal Bubble Shape * Add Label to tooltip * Update text frame when traitCollection did change * Update CharcoalTooltipView.swift * Add CharcoalTooltip * Can debug show on method * Can layout point * Can redraw target point * Update CharcoalTooltip.swift * Refine tooltip display * Share the logic * Use interaction mode * Use CharcoalOverlayContainerView * Update CharcoalOverlay.swift * Refactor to ChacoalOverlayManager * Makes CharcoalIdentifiableOverlayView Identifiable * Refine layout logic * Add display(view: CharcoalIdentifiableOverlayView) * Add tooltip to uikit example * Update Tooltips.swift * Add CharcoalIdentifiableOverlayDelegate * Reformat * Update StringExtension.swift * Add to UIKitSample * Fix public requirements * Reformat * Use touch began to handle dismiss on touch * Update CharcoalIdentifiableOverlayView.swift * Move show logic out * Reformat * Refine dismiss method * Add ActionContent and ActionComplete callback * Update CharcoalBubbleShape_UIKit.swift * Reformat * Fix name * Fix geometry * Revert "Fix geometry" This reverts commit a89bf66feb82de8b2da1ba8c242ef4ac09d8f99c. * Fix proxy name * Adjust unused text * Replace charcoal logo * Remove conditional modifier * Add default dismiss time to toasts * Reformat --- .../CharcoalSwiftUISample/ContentView.swift | 4 + .../Media.xcassets/Contents.json | 6 + .../SnackbarDemo.imageset/Contents.json | 12 + .../SnackbarDemo.imageset/charcoal-logo.png | Bin 0 -> 2144 bytes .../CharcoalSwiftUISample/ToastsView.swift | 170 ++++++++++ .../CharcoalIdentifiableOverlayView.swift | 29 +- .../CharcoalOverlayContainerModifier.swift | 21 +- .../Overlay/CharcoalPopupProtocol.swift | 26 ++ .../Components/Toast/CharcoalSnackBar.swift | 295 ++++++++++++++++++ .../Components/Toast/CharcoalToast.swift | 290 +++++++++++++++++ .../CharcoalToastAnimatableModifier.swift | 87 ++++++ .../CharcoalToastDraggableModifier.swift | 80 +++++ .../Toast/CharcoalToastProtocol.swift | 38 +++ .../Components/Tooltip/CharcoalTooltip.swift | 156 ++++++--- .../Components/Tooltip/CharcoalTooltip.swift | 2 - 15 files changed, 1140 insertions(+), 76 deletions(-) create mode 100644 CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/Contents.json create mode 100644 CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/Contents.json create mode 100644 CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png create mode 100644 CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ToastsView.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastAnimatableModifier.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift create mode 100644 Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastProtocol.swift diff --git a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ContentView.swift b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ContentView.swift index edba19f5b..ad331062c 100644 --- a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ContentView.swift +++ b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ContentView.swift @@ -46,12 +46,16 @@ public struct ContentView: View { NavigationLink(destination: TooltipsView()) { Text("Tooltips") } + NavigationLink(destination: ToastsView()) { + Text("Toasts") + } NavigationLink(destination: SpinnersView()) { Text("Spinners") } } .navigationBarTitle("Charcoal") } + .charcoalOverlayContainer() .preferredColorScheme(isDarkModeOn ? .dark : .light) } } diff --git a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/Contents.json b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/Contents.json b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/Contents.json new file mode 100644 index 000000000..f10cd6bfb --- /dev/null +++ b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "charcoal-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..85a24cbc9a88afcb1621cdd66b36e3614af4e617 GIT binary patch literal 2144 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 literal 0 HcmV?d00001 diff --git a/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ToastsView.swift b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ToastsView.swift new file mode 100644 index 000000000..9b1fef4b4 --- /dev/null +++ b/CharcoalSwiftUISample/Sources/CharcoalSwiftUISample/ToastsView.swift @@ -0,0 +1,170 @@ +import Charcoal +import SwiftUI + +public struct ToastsView: View { + @State var isPresenting = false + @State var isPresenting2 = false + @State var isPresenting3 = false + @State var isPresenting4 = false + + @State var isPresentingToast = false + @State var isPresentingToast2 = false + @State var isPresentingToast3 = false + @State var isPresentingToast4 = false + + public var body: some View { + List { + Section(header: Text("SnackBar")) { + Button { + isPresenting.toggle() + } label: { + Text("SnackBar") + } + .charcoalSnackBar( + isPresenting: $isPresenting, + text: "ブックマークしました" + ) + + VStack(alignment: .leading) { + Button { + isPresenting2.toggle() + } label: { + Text("SnackBar") + } + Text("with Action") + } + .charcoalSnackBar( + isPresenting: $isPresenting2, + screenEdge: .top, + text: "ブックマークしました", + action: { + Button { + print("Tapped") + } label: { + Text("編集") + } + } + ) + + VStack(alignment: .leading) { + Button { + isPresenting3.toggle() + } label: { + Text("SnackBar") + } + Text("with Action and Thumbnail") + } + .charcoalSnackBar( + isPresenting: $isPresenting3, + text: "ブックマークしました", + thumbnailImage: Image("SnackbarDemo", bundle: Bundle.module), + action: { + Button { + print("Tapped") + } label: { + Text("編集") + } + } + ) + + VStack(alignment: .leading) { + Button { + isPresenting4.toggle() + } label: { + Text("SnackBar") + } + Text("Auto dismiss after 2 seconds") + } + .charcoalSnackBar( + isPresenting: $isPresenting4, + text: "ブックマークしました", + thumbnailImage: Image("SnackbarDemo", bundle: Bundle.module), + dismissAfter: 2, + action: { + Button { + print("Tapped") + } label: { + Text("編集") + } + } + ) + } + + Section(header: Text("Toasts")) { + Button { + isPresentingToast.toggle() + } label: { + Text("Toast") + } + .charcoalToast( + isPresenting: $isPresentingToast, + text: "テキストメッセージ" + ) + + VStack(alignment: .leading) { + Button { + isPresentingToast2.toggle() + } label: { + Text("Toast") + } + Text("with Action") + } + .charcoalToast( + isPresenting: $isPresentingToast2, + screenEdge: .top, + text: "テキストメッセージ", + action: { + Button { + isPresentingToast2 = false + } label: { + Image(charocalIcon: .remove16) + .renderingMode(.template) + } + } + ) + + VStack(alignment: .leading) { + Button { + isPresentingToast3.toggle() + } label: { + Text("Toast(Error Appearance)") + } + Text("with Custom Animation") + } + .charcoalToast( + isPresenting: $isPresentingToast3, + text: "ブックマークしました", + appearance: .error, + animationConfiguration: CharcoalToastAnimationConfiguration(enablePositionAnimation: false, animation: .easeInOut), + action: { + Button { + isPresentingToast3 = false + } label: { + Image(charocalIcon: .remove16) + .renderingMode(.template) + } + } + ) + + VStack(alignment: .leading) { + Button { + isPresentingToast4.toggle() + } label: { + Text("Toast(Error Appearance)") + } + Text("Auto dismiss after 2 seconds") + } + .charcoalToast( + isPresenting: $isPresentingToast4, + text: "ブックマークしました", + dismissAfter: 2, + appearance: .error + ) + } + }.navigationBarTitle("Toasts") + } +} + +#Preview { + ToastsView().charcoalOverlayContainer() +} diff --git a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalIdentifiableOverlayView.swift b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalIdentifiableOverlayView.swift index fb6c82b77..7c71af704 100644 --- a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalIdentifiableOverlayView.swift +++ b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalIdentifiableOverlayView.swift @@ -1,30 +1,15 @@ import SwiftUI -struct CharcoalIdentifiableOverlayView: View { +struct CharcoalIdentifiableOverlayView: View, Identifiable { typealias IDValue = UUID + + /// The unique ID of the overlay. let id: IDValue - var contentView: AnyView - @Binding var isPresenting: Bool + + /// The content to display in the overlay. + let contentView: AnyView var body: some View { - ZStack { - if isPresenting { - Color.clear - .contentShape(Rectangle()) - .simultaneousGesture( - TapGesture() - .onEnded { _ in - isPresenting = false - } - ) - .simultaneousGesture( - DragGesture() - .onChanged { _ in - isPresenting = false - } - ) - contentView - } - }.animation(.easeInOut(duration: 0.2), value: isPresenting) + contentView } } diff --git a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalOverlayContainerModifier.swift b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalOverlayContainerModifier.swift index af234516d..07e3be725 100644 --- a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalOverlayContainerModifier.swift +++ b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalOverlayContainerModifier.swift @@ -12,39 +12,38 @@ struct CharcoalOverlayContainerModifier: ViewModifier { } } -typealias CharcoalPopupView = Equatable & View - -struct CharcoalOverlayContainerChild: ViewModifier { +struct CharcoalOverlayUpdaterContainer: ViewModifier { @EnvironmentObject var viewManager: CharcoalContainerManager @Binding var isPresenting: Bool - var view: SubContent + let view: SubContent let viewID: UUID func createOverlayView(view: SubContent) -> CharcoalIdentifiableOverlayView { - return CharcoalIdentifiableOverlayView(id: viewID, contentView: AnyView(view), isPresenting: $isPresenting) + return CharcoalIdentifiableOverlayView(id: viewID, contentView: AnyView(view)) + } + + func updateView(view: SubContent) { + viewManager.addView(view: createOverlayView(view: view)) } func body(content: Content) -> some View { content .onChange(of: isPresenting) { newValue in if newValue { - let newView = createOverlayView(view: view) - viewManager.addView(view: newView) + updateView(view: view) } } .onChange(of: view) { newValue in if isPresenting { - let newView = createOverlayView(view: newValue) - viewManager.addView(view: newView) + updateView(view: newValue) } } .onAppear { // onAppear is needed if the overlay is presented by default - let newView = createOverlayView(view: view) - viewManager.addView(view: newView) + updateView(view: view) } } } diff --git a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift new file mode 100644 index 000000000..fae2d331c --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift @@ -0,0 +1,26 @@ +import SwiftUI + +typealias CharcoalPopupProtocol = Equatable & Identifiable & View + +public enum CharcoalPopupViewEdge { + case top + case bottom + + var alignment: Alignment { + switch self { + case .top: + return .top + case .bottom: + return .bottom + } + } + + var direction: CGFloat { + switch self { + case .top: + return 1 + case .bottom: + return -1 + } + } +} diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift new file mode 100644 index 000000000..2a1f73364 --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift @@ -0,0 +1,295 @@ +import SwiftUI + +struct CharcoalSnackBar: CharcoalPopupProtocol, CharcoalToastBase, CharcoalToastActionable, CharcoalToastDraggable { + typealias IDValue = UUID + + let id: IDValue + + let text: String + + /// The thumbnail image of the snackbar + let thumbnailImage: Image? + + let maxWidth: CGFloat + + let cornerRadius: CGFloat = 32 + + let borderColor: Color + + let borderLineWidth: CGFloat = 1 + + let screenEdge: CharcoalPopupViewEdge + + let screenEdgeSpacing: CGFloat + + /// The content of the action view + let action: ActionContent? + + @State private var tooltipSize: CGSize = .zero + + /// A binding to whether the overlay is presented. + @Binding var isPresenting: Bool + + let dismissAfter: TimeInterval? + + @State var isActuallyPresenting: Bool = false + + var animationConfiguration: CharcoalToastAnimationConfiguration + + @State var offset = CGSize.zero + + @State var dragVelocity = CGSize.zero + + @State var isDragging = false + + init( + id: IDValue, + text: String, + maxWidth: CGFloat = 312, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat, + thumbnailImage: Image?, + @ViewBuilder action: () -> ActionContent?, + isPresenting: Binding, + dismissAfter: TimeInterval?, + animationConfiguration: CharcoalToastAnimationConfiguration = .default + ) { + self.id = id + self.text = text + self.maxWidth = maxWidth + self.thumbnailImage = thumbnailImage + self.action = action() + self.screenEdgeSpacing = screenEdgeSpacing + _isPresenting = isPresenting + self.dismissAfter = dismissAfter + self.screenEdge = screenEdge + self.animationConfiguration = animationConfiguration + borderColor = Color(CharcoalAsset.ColorPaletteGenerated.border.color) + } + + var body: some View { + ZStack(alignment: screenEdge.alignment) { + Color.clear + HStack(spacing: 0) { + if let thumbnailImage = thumbnailImage { + thumbnailImage + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + } + HStack(spacing: 16) { + Text(text) + .charcoalTypography14Bold(isSingleLine: true) + .foregroundColor(Color(CharcoalAsset.ColorPaletteGenerated.text1.color)) + + if let action = action { + action + .charcoalDefaultButton(size: .small) + } + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + } + .background( + Color(CharcoalAsset.ColorPaletteGenerated.background1.color) + ) + .charcoalAnimatableToast( + isPresenting: $isPresenting, + isActuallyPresenting: $isActuallyPresenting, + isDragging: $isDragging, + tooltipSize: $tooltipSize, + cornerRadius: cornerRadius, + borderColor: borderColor, + borderLineWidth: borderLineWidth, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + dismissAfter: dismissAfter, + animationConfiguration: animationConfiguration + ) + .charcoalDraggableToast(screenEdge: screenEdge, isPresenting: $isPresenting, offset: $offset, dragVelocity: $dragVelocity, isDragging: $isDragging) + } + .frame(minWidth: 0, maxWidth: maxWidth, alignment: .center) + } + + static func == (lhs: CharcoalSnackBar, rhs: CharcoalSnackBar) -> Bool { + return lhs.text == rhs.text && lhs.maxWidth == rhs.maxWidth && lhs.thumbnailImage == rhs.thumbnailImage && lhs.isPresenting == rhs.isPresenting + } +} + +struct CharcoalSnackBarModifier: ViewModifier { + /// Presentation `Binding` + @Binding var isPresenting: Bool + + /// The edge of the screen where the snackbar will be presented + let screenEdge: CharcoalPopupViewEdge + + /// The spacing between the snackbar and the screen edge + let screenEdgeSpacing: CGFloat + + /// Text to be displayed in the snackbar + let text: String + + /// The thumbnail image to be displayed in the snackbar + let thumbnailImage: Image? + + /// The action to be displayed in the snackbar + @ViewBuilder let action: () -> ActionContent? + + /// Assign a unique ID to the view + @State var viewID = UUID() + + /// The overlay will be dismissed after a certain time interval. + let dismissAfter: TimeInterval? + + func body(content: Content) -> some View { + content + .overlay(Color.clear + .modifier( + CharcoalOverlayUpdaterContainer( + isPresenting: $isPresenting, + + view: CharcoalSnackBar( + id: viewID, + text: text, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + thumbnailImage: thumbnailImage, + action: action, + isPresenting: $isPresenting, + dismissAfter: dismissAfter + ), + viewID: viewID + ))) + } +} + +public extension View { + /** + Add a Snackbar to the view + + - Parameters: + - isPresenting: A binding to whether the Snackbar is presented. + - text: The text to be displayed in the snackbar. + - thumbnailImage: The thumbnail image to be displayed in the snackbar. + - dismissAfter: The overlay will be dismissed after a certain time interval. + - screenEdge: The edge of the screen where the snackbar will be presented + - screenEdgeSpacing: The spacing between the snackbar and the screen edge + - action: The action to be displayed in the snackbar. + + # Example # + ```swift + Text("Hello").charcoalSnackBar(isPresenting: $isPresenting, text: "Hello") + ``` + */ + func charcoalSnackBar( + isPresenting: Binding, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat = 120, + text: String, + thumbnailImage: Image? = nil, + dismissAfter: TimeInterval? = 2, + @ViewBuilder action: @escaping () -> Content = { EmptyView() } + ) -> some View where Content: View { + return modifier( + CharcoalSnackBarModifier( + isPresenting: isPresenting, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + text: text, + thumbnailImage: thumbnailImage, + action: action, + dismissAfter: dismissAfter + ) + ) + } +} + +private extension UIColor { + func imageWithColor(width: Int, height: Int) -> UIImage { + let size = CGSize(width: width, height: height) + return UIGraphicsImageRenderer(size: size).image { rendererContext in + self.setFill() + rendererContext.fill(CGRect(origin: .zero, size: size)) + } + } +} + +private struct SnackBarsPreviewView: View { + @State var isPresenting = true + + @State var isPresenting2 = true + + @State var isPresenting3 = true + + @State var textOfLabel = "Hello" + + var body: some View { + NavigationView(content: { + TabView { + ZStack { + ZStack { + Button { + isPresenting.toggle() + isPresenting3.toggle() + } label: { + Text("Toggle SnackBar") + } + } + .charcoalSnackBar( + isPresenting: $isPresenting, + screenEdge: .top, + text: "ブックマークしました", + thumbnailImage: Image(uiImage: CharcoalAsset.ColorPaletteGenerated.border.color.imageWithColor(width: 64, height: 64)), + action: { + Button { + print("Tapped") + } label: { + Text("編集") + } + } + ) + .charcoalSnackBar( + isPresenting: $isPresenting2, + screenEdge: .bottom, + text: "ブックマークしました", + dismissAfter: 2, + action: { + Button { + print("Tapped") + } label: { + Text("編集") + } + } + ) + .charcoalSnackBar( + isPresenting: $isPresenting3, + screenEdgeSpacing: 275, + text: "ブックマークしました" + ) + } + + .tabItem { + Image(systemName: "1.circle") + Text("First") + } + + Text("Second Tab") + .tabItem { + Image(systemName: "2.circle") + Text("Second") + } + + Text("Third Tab") + .tabItem { + Image(systemName: "3.circle") + Text("Third") + } + } + .navigationTitle("Snackbar") + }) + .charcoalOverlayContainer() + } +} + +#Preview { + SnackBarsPreviewView() +} diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift new file mode 100644 index 000000000..8c46d4352 --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift @@ -0,0 +1,290 @@ +import SwiftUI + +struct CharcoalToast: CharcoalPopupProtocol, CharcoalToastBase, CharcoalToastActionable { + typealias IDValue = UUID + + let id: IDValue + + let text: String + + let maxWidth: CGFloat + + /// The corner radius of the Toast + let cornerRadius: CGFloat = 32 + + let borderColor: Color + + let borderLineWidth: CGFloat = 2 + + let screenEdge: CharcoalPopupViewEdge + + let screenEdgeSpacing: CGFloat + + /// The content of the action view + let action: ActionContent? + + @State private var tooltipSize: CGSize = .zero + + /// A binding to whether the overlay is presented. + @Binding var isPresenting: Bool + + let dismissAfter: TimeInterval? + + /// The appearance of the Toast + let appearance: CharcoalToastAppearance + + @State var isActuallyPresenting: Bool = false + + let animationConfiguration: CharcoalToastAnimationConfiguration + + init( + id: IDValue, + text: String, + maxWidth: CGFloat = 312, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat, + @ViewBuilder action: () -> ActionContent?, + isPresenting: Binding, + dismissAfter: TimeInterval?, + appearance: CharcoalToastAppearance = .success, + animationConfiguration: CharcoalToastAnimationConfiguration + ) { + self.id = id + self.text = text + self.maxWidth = maxWidth + self.action = action() + self.screenEdgeSpacing = screenEdgeSpacing + _isPresenting = isPresenting + self.dismissAfter = dismissAfter + self.appearance = appearance + self.screenEdge = screenEdge + self.animationConfiguration = animationConfiguration + borderColor = Color(CharcoalAsset.ColorPaletteGenerated.background1.color) + } + + var body: some View { + ZStack(alignment: screenEdge.alignment) { + Color.clear + HStack(spacing: 0) { + HStack(spacing: 8) { + Text(text) + .charcoalTypography14Bold(isSingleLine: true) + .foregroundColor(Color(CharcoalAsset.ColorPaletteGenerated.background1.color)) + + if let action = action { + action + .foregroundColor(Color(CharcoalAsset.ColorPaletteGenerated.background1.color)) + } + } + .padding(EdgeInsets(top: 8, leading: 24, bottom: 8, trailing: 24)) + } + .background( + appearance.background + ) + .charcoalAnimatableToast( + isPresenting: $isPresenting, + isActuallyPresenting: $isActuallyPresenting, + tooltipSize: $tooltipSize, + cornerRadius: cornerRadius, + borderColor: borderColor, + borderLineWidth: borderLineWidth, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + dismissAfter: dismissAfter, + animationConfiguration: animationConfiguration + ) + } + .frame(minWidth: 0, maxWidth: maxWidth, alignment: .center) + } + + static func == (lhs: CharcoalToast, rhs: CharcoalToast) -> Bool { + return lhs.text == rhs.text && lhs.maxWidth == rhs.maxWidth && lhs.isPresenting == rhs.isPresenting + } +} + +struct PopupViewSizeKey: PreferenceKey { + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } + + static var defaultValue: CGSize = .zero +} + +public enum CharcoalToastAppearance { + case success + case error + + var background: Color { + switch self { + case .success: + return Color(CharcoalAsset.ColorPaletteGenerated.success.color) + case .error: + return Color(CharcoalAsset.ColorPaletteGenerated.assertive.color) + } + } +} + +public struct CharcoalToastAnimationConfiguration { + public let enablePositionAnimation: Bool + public let animation: Animation + + public init(enablePositionAnimation: Bool, animation: Animation) { + self.enablePositionAnimation = enablePositionAnimation + self.animation = animation + } + + public static let `default` = CharcoalToastAnimationConfiguration(enablePositionAnimation: true, animation: .spring()) +} + +struct CharcoalToastModifier: ViewModifier { + /// Presentation `Binding` + @Binding var isPresenting: Bool + + let screenEdge: CharcoalPopupViewEdge + /// The spacing between the snackbar and the screen edge + let screenEdgeSpacing: CGFloat + + /// Text to be displayed in the snackbar + let text: String + + /// The action to be displayed in the snackbar + @ViewBuilder let action: () -> ActionContent? + + /// Assign a unique ID to the view + @State var viewID = UUID() + + /// The overlay will be dismissed after a certain time interval. + let dismissAfter: TimeInterval? + + /// The appearance of the Toast + let appearance: CharcoalToastAppearance + + let animationConfiguration: CharcoalToastAnimationConfiguration + + func body(content: Content) -> some View { + content + .overlay(Color.clear + .modifier( + CharcoalOverlayUpdaterContainer( + isPresenting: $isPresenting, + + view: CharcoalToast( + id: viewID, + text: text, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + action: action, + isPresenting: $isPresenting, + dismissAfter: dismissAfter, + appearance: appearance, + animationConfiguration: animationConfiguration + ), + viewID: viewID + ))) + } +} + +public extension View { + /** + Add a Toast to the view + + - Parameters: + - isPresenting: A binding to whether the Toast is presented. + - dismissAfter: The overlay will be dismissed after a certain time interval. + - screenEdge: The edge of the screen where the snackbar will be presented + - screenEdgeSpacing: The spacing between the snackbar and the screen edge + - text: The text to be displayed in the snackbar. + - action: The action to be displayed in the snackbar. + - appearance: The appearance of the Toast + + # Example # + ```swift + Text("Hello").charcoalToast(isPresenting: $isPresenting, text: "Hello") + ``` + */ + func charcoalToast( + isPresenting: Binding, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat = 96, + text: String, + dismissAfter: TimeInterval? = 2, + appearance: CharcoalToastAppearance = .success, + animationConfiguration: CharcoalToastAnimationConfiguration = .default, + @ViewBuilder action: @escaping () -> Content = { EmptyView() } + ) -> some View where Content: View { + return modifier( + CharcoalToastModifier( + isPresenting: isPresenting, + screenEdge: screenEdge, + screenEdgeSpacing: screenEdgeSpacing, + text: text, + action: action, + dismissAfter: dismissAfter, + appearance: appearance, animationConfiguration: animationConfiguration + ) + ) + } +} + +private struct ToastsPreviewView: View { + @State var isPresenting = true + + @State var isPresenting2 = true + + @State var isPresenting3 = true + + @State var textOfLabel = "Hello" + + var body: some View { + ZStack { + Color.clear + ZStack { + Button { + isPresenting.toggle() + isPresenting3.toggle() + } label: { + Text("Toggle SnackBar") + } + } + .charcoalToast( + isPresenting: $isPresenting, + screenEdge: .top, + text: "テキストメッセージ", + action: { + Button { + isPresenting = false + } label: { + Image(charocalIcon: .remove16) + .renderingMode(.template) + } + } + ) + .charcoalToast( + isPresenting: $isPresenting2, + screenEdgeSpacing: 192, + text: "テキストメッセージ", + dismissAfter: 2, + appearance: .error, + action: { + Button { + isPresenting2 = false + } label: { + Image(charocalIcon: .remove16) + .renderingMode(.template) + } + } + ) + .charcoalToast( + isPresenting: $isPresenting3, + screenEdgeSpacing: 275, + text: "テキストメッセージ", + animationConfiguration: .init(enablePositionAnimation: false, animation: .easeInOut(duration: 0.2)) + ) + } + .charcoalOverlayContainer() + } +} + +#Preview { + ToastsPreviewView() +} diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastAnimatableModifier.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastAnimatableModifier.swift new file mode 100644 index 000000000..0ea4d76bd --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastAnimatableModifier.swift @@ -0,0 +1,87 @@ +import Combine +import SwiftUI + +struct CharcoalToastAnimatableModifier: ViewModifier, CharcoalToastBase { + var text: String = "" + + var maxWidth: CGFloat + + @Binding var isPresenting: Bool + + let cornerRadius: CGFloat + + let borderColor: Color + + let borderLineWidth: CGFloat + + let screenEdge: CharcoalPopupViewEdge + + let screenEdgeSpacing: CGFloat + + @Binding var tooltipSize: CGSize + + @Binding var isActuallyPresenting: Bool + + let animationConfiguration: CharcoalToastAnimationConfiguration + + let dismissAfter: TimeInterval? + + @Binding var isDragging: Bool + + @State var timer: Timer? + + func body(content: Content) -> some View { + content + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .overlay(RoundedRectangle(cornerRadius: cornerRadius).stroke(borderColor, lineWidth: borderLineWidth)) + .overlay( + GeometryReader(content: { proxy in + Color.clear.preference(key: PopupViewSizeKey.self, value: proxy.size) + }) + ) + .offset(CGSize(width: 0, height: animationConfiguration.enablePositionAnimation ? (isActuallyPresenting ? screenEdge.direction * screenEdgeSpacing : -screenEdge.direction * (tooltipSize.height)) : screenEdge.direction * screenEdgeSpacing)) + .opacity(isActuallyPresenting ? 1 : 0) + .onPreferenceChange(PopupViewSizeKey.self, perform: { value in + tooltipSize = value + }) + .onChange(of: isActuallyPresenting) { newValue in + if let dismissAfter = dismissAfter, newValue { + timer = Timer.scheduledTimer(withTimeInterval: dismissAfter, repeats: false, block: { _ in + if !isDragging { + isPresenting = false + } + }) + } + } + .onChange(of: isDragging, perform: { _ in + if isDragging { + timer?.invalidate() + } + }) + .animation(animationConfiguration.animation, value: isActuallyPresenting) + .onChange(of: isPresenting, perform: { newValue in + isActuallyPresenting = newValue + }) + .onAppear { + isActuallyPresenting = isPresenting + } + } +} + +extension View { + func charcoalAnimatableToast( + isPresenting: Binding, + isActuallyPresenting: Binding, + isDragging: Binding = Binding.constant(false), + tooltipSize: Binding, + cornerRadius: CGFloat, + borderColor: Color, + borderLineWidth: CGFloat, + screenEdge: CharcoalPopupViewEdge, + screenEdgeSpacing: CGFloat, + dismissAfter: TimeInterval? = nil, + animationConfiguration: CharcoalToastAnimationConfiguration + ) -> some View { + modifier(CharcoalToastAnimatableModifier(maxWidth: 0, isPresenting: isPresenting, cornerRadius: cornerRadius, borderColor: borderColor, borderLineWidth: borderLineWidth, screenEdge: screenEdge, screenEdgeSpacing: screenEdgeSpacing, tooltipSize: tooltipSize, isActuallyPresenting: isActuallyPresenting, animationConfiguration: animationConfiguration, dismissAfter: dismissAfter, isDragging: isDragging)) + } +} diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift new file mode 100644 index 000000000..f883d53fe --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct CharcoalToastDraggableModifier: ViewModifier, CharcoalToastDraggable { + let screenEdge: CharcoalPopupViewEdge + + @Binding var isPresenting: Bool + + @Binding var offset: CGSize + + @Binding var dragVelocity: CGSize + + @Binding var isDragging: Bool + + func body(content: Content) -> some View { + content + .offset(y: offset.height) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + let translationInDirection = gesture.translation.height * screenEdge.direction + dragVelocity = gesture.velocity + + isDragging = true + if translationInDirection < 0 { + offset = CGSize( + width: 0, + height: gesture.translation.height + ) + } else { + // the less the faster resistance + let limit: CGFloat = 60 + let yOff = gesture.translation.height + let dist = sqrt(yOff * yOff) + let factor = 1 / (dist / limit + 1) + + offset = CGSize( + width: 0, + height: gesture.translation.height * factor + ) + } + } + .onEnded { _ in + isDragging = false + let movingVelocityInDirection = dragVelocity.height * screenEdge.direction + let offsetInDirection = offset.height * screenEdge.direction + + if offsetInDirection < -50 || movingVelocityInDirection < -100 { + // remove the card + isPresenting = false + let animation = Animation.interpolatingSpring(initialVelocity: dragVelocity.height) + withAnimation(animation) { + offset = .zero + } + } else { + withAnimation(.bouncy) { + offset = .zero + } + } + } + ) + } +} + +extension View { + func charcoalDraggableToast( + screenEdge: CharcoalPopupViewEdge, + isPresenting: Binding, + offset: Binding, + dragVelocity: Binding, + isDragging: Binding + ) -> some View { + modifier(CharcoalToastDraggableModifier( + screenEdge: screenEdge, + isPresenting: isPresenting, + offset: offset, + dragVelocity: dragVelocity, + isDragging: isDragging + )) + } +} diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastProtocol.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastProtocol.swift new file mode 100644 index 000000000..3048000f0 --- /dev/null +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastProtocol.swift @@ -0,0 +1,38 @@ +import SwiftUI + +protocol CharcoalToastBase { + /// The text of the toast + var text: String { get } + /// The maximum width of the toast + var maxWidth: CGFloat { get } + /// The toast will be dismissed after a certain time interval. + var dismissAfter: TimeInterval? { get } + /// The edge of the screen where the toast will be presented + var screenEdge: CharcoalPopupViewEdge { get } + /// The spacing between the toast and the screen edge + var screenEdgeSpacing: CGFloat { get } + /// If the toast is currently being presented + var isPresenting: Bool { get set } + /// Radius of the toast corners + var cornerRadius: CGFloat { get } + /// Color of the toast border + var borderColor: Color { get } + /// Width of the toast border line + var borderLineWidth: CGFloat { get } + /// The configuration of the toast animation + var animationConfiguration: CharcoalToastAnimationConfiguration { get } +} + +protocol CharcoalToastActionable { + associatedtype ActionContent: View + /// The content of the action view + var action: ActionContent? { get } +} + +protocol CharcoalToastDraggable { + var offset: CGSize { get set } + + var dragVelocity: CGSize { get set } + + var isDragging: Bool { get set } +} diff --git a/Sources/CharcoalSwiftUI/Components/Tooltip/CharcoalTooltip.swift b/Sources/CharcoalSwiftUI/Components/Tooltip/CharcoalTooltip.swift index d0d1b8c3c..36e05b0bd 100644 --- a/Sources/CharcoalSwiftUI/Components/Tooltip/CharcoalTooltip.swift +++ b/Sources/CharcoalSwiftUI/Components/Tooltip/CharcoalTooltip.swift @@ -1,6 +1,10 @@ import SwiftUI -struct CharcoalTooltip: CharcoalPopupView { +struct CharcoalTooltip: CharcoalPopupProtocol { + typealias IDValue = UUID + + /// The unique ID of the overlay. + let id: IDValue /// The text of the tooltip let text: String @@ -24,14 +28,35 @@ struct CharcoalTooltip: CharcoalPopupView { @State private var tooltipSize: CGSize = .zero + /// A binding to whether the overlay is presented. + @Binding var isPresenting: Bool + + /// If true, the overlay will be dismissed when the user taps outside of the overlay. + let dismissOnTouchOutside: Bool + + /// The overlay will be dismissed after a certain time interval. + let dismissAfter: TimeInterval? + var offset: CGSize { CGSize(width: targetFrame.midX - (tooltipSize.width / 2.0), height: targetFrame.maxY) } - init(text: String, targetFrame: CGRect, maxWidth: CGFloat = 184) { + init( + id: IDValue, + text: String, + targetFrame: CGRect, + maxWidth: CGFloat = 184, + isPresenting: Binding, + dismissOnTouchOutside: Bool = true, + dismissAfter: TimeInterval? = nil + ) { + self.id = id self.text = text self.targetFrame = targetFrame self.maxWidth = maxWidth + _isPresenting = isPresenting + self.dismissOnTouchOutside = dismissOnTouchOutside + self.dismissAfter = dismissAfter } func tooltipX(canvasGeometrySize: CGSize) -> CGFloat { @@ -58,43 +83,74 @@ struct CharcoalTooltip: CharcoalPopupView { return min(minX, edgeBottom) } - public var body: some View { - GeometryReader(content: { proxy in - VStack { - Text(text) - .charcoalTypography12Regular() - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(Color(CharcoalAsset.ColorPaletteGenerated.text5.color)) - .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) - .background(GeometryReader(content: { proxy in - let tooltipOrigin = proxy.frame(in: .global).origin - TooltipBubbleShape( - targetPoint: - CGPoint( - x: targetFrame.midX - tooltipOrigin.x, - y: targetFrame.maxY - tooltipOrigin.y - ), - arrowHeight: arrowHeight, - cornerRadius: cornerRadius - ) - .fill(Color(CharcoalAsset.ColorPaletteGenerated.surface8.color)) - .preference(key: TooltipSizeKey.self, value: proxy.size) - })) - .offset(CGSize( - width: tooltipX(canvasGeometrySize: proxy.size), - height: tooltipY(canvasGeometrySize: proxy.size) - )) - .onPreferenceChange(TooltipSizeKey.self, perform: { value in - tooltipSize = value - }) - .animation(.none, value: tooltipSize) - .animation(.none, value: targetFrame) - }.frame(minWidth: 0, maxWidth: maxWidth, alignment: .leading) - }) + var body: some View { + ZStack { + if dismissOnTouchOutside && isPresenting { + Color.clear + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture() + .onEnded { _ in + isPresenting = false + } + ) + .simultaneousGesture( + DragGesture() + .onChanged { _ in + isPresenting = false + } + ) + } else { + Color.clear + } + if isPresenting { + GeometryReader(content: { canvasGeometry in + VStack { + Text(text) + .charcoalTypography12Regular() + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(Color(CharcoalAsset.ColorPaletteGenerated.text5.color)) + .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) + .background(GeometryReader(content: { tooltipGeometry in + let tooltipOrigin = tooltipGeometry.frame(in: .global).origin + TooltipBubbleShape( + targetPoint: + CGPoint( + x: targetFrame.midX - tooltipOrigin.x, + y: targetFrame.maxY - tooltipOrigin.y + ), + arrowHeight: arrowHeight, + cornerRadius: cornerRadius + ) + .fill(Color(CharcoalAsset.ColorPaletteGenerated.surface8.color)) + .preference(key: TooltipSizeKey.self, value: tooltipGeometry.size) + })) + .offset(CGSize( + width: tooltipX(canvasGeometrySize: canvasGeometry.size), + height: tooltipY(canvasGeometrySize: canvasGeometry.size) + )) + .onPreferenceChange(TooltipSizeKey.self, perform: { value in + tooltipSize = value + }) + .animation(.none, value: tooltipSize) + .animation(.none, value: targetFrame) + } + .frame(minWidth: 0, maxWidth: maxWidth, alignment: .leading) + }) + .onAppear { + if let dismissAfter = dismissAfter { + DispatchQueue.main.asyncAfter(deadline: .now() + dismissAfter) { + isPresenting = false + } + } + } + } + } + .animation(.easeInOut(duration: 0.2), value: isPresenting) } - public static func == (lhs: CharcoalTooltip, rhs: CharcoalTooltip) -> Bool { + static func == (lhs: CharcoalTooltip, rhs: CharcoalTooltip) -> Bool { return lhs.text == rhs.text && lhs.targetFrame == rhs.targetFrame && lhs.maxWidth == rhs.maxWidth && lhs.tooltipSize == rhs.tooltipSize } } @@ -117,11 +173,24 @@ struct CharcoalTooltipModifier: ViewModifier { /// Assign a unique ID to the view @State var viewID = UUID() + /// The overlay will be dismissed after a certain time interval. + let dismissAfter: TimeInterval? + func body(content: Content) -> some View { content .overlay(GeometryReader(content: { proxy in Color.clear - .modifier(CharcoalOverlayContainerChild(isPresenting: $isPresenting, view: CharcoalTooltip(text: text, targetFrame: proxy.frame(in: .global)), viewID: viewID)) + .modifier(CharcoalOverlayUpdaterContainer( + isPresenting: $isPresenting, + view: CharcoalTooltip( + id: viewID, + text: text, + targetFrame: proxy.frame(in: .global), + isPresenting: $isPresenting, + dismissAfter: dismissAfter + ), + viewID: viewID + )) })) } } @@ -141,9 +210,10 @@ public extension View { */ func charcoalTooltip( isPresenting: Binding, - text: String + text: String, + dismissAfter: TimeInterval? = nil ) -> some View { - return modifier(CharcoalTooltipModifier(isPresenting: isPresenting, text: text)) + return modifier(CharcoalTooltipModifier(isPresenting: isPresenting, text: text, dismissAfter: dismissAfter)) } } @@ -213,7 +283,11 @@ private struct TooltipsPreviewView: View { Text("Bottom") } .charcoalPrimaryButton(size: .medium) - .charcoalTooltip(isPresenting: $isPresenting5, text: "Hello World This is a tooltip and here is testing it's multiple line feature") + .charcoalTooltip( + isPresenting: $isPresenting5, + text: "Hello World This is a tooltip and here is testing it's multiple line feature", + dismissAfter: 2 + ) .offset(CGSize(width: proxy.size.width - 240, height: proxy.size.height - 40)) Button { diff --git a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift index 39a1167ca..e1326df36 100644 --- a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift +++ b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift @@ -24,12 +24,10 @@ public extension CharcoalTooltip { let containerView = ChacoalOverlayManager.shared.layout(view: tooltip, interactionMode: .dimissOnTouch, on: on) containerView.delegate = ChacoalOverlayManager.shared - let mainView = ChacoalOverlayManager.shared.mainView! let spacingToScreen: CGFloat = 16 let gap: CGFloat = 4 let viewSize = tooltip.intrinsicContentSize - let anchorPoint = anchorView.superview!.convert(anchorView.frame.origin, to: containerView) let targetPoint = anchorView.superview!.convert(anchorView.center, to: tooltip) let newAnchorRect = CGRect(x: anchorPoint.x, y: anchorPoint.y, width: anchorView.frame.width, height: anchorView.frame.height)