diff --git a/.swiftformat b/.swiftformat index 37fcf7d79..b8a564540 100644 --- a/.swiftformat +++ b/.swiftformat @@ -30,4 +30,4 @@ --wraparguments before-first --wrapcollections before-first ---exclude Sources/Charcoal/Generated +--exclude Sources/CharcoalShared/Generated diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift index ef7f73a5a..3cca50152 100644 --- a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift @@ -50,6 +50,7 @@ public final class ContentViewController: UIViewController { case typographies = "Typographies" case icons = "Icons" case tooltips = "Tooltips" + case toasts = "Toasts" case spinners = "Spinners" var viewController: UIViewController { @@ -68,6 +69,8 @@ public final class ContentViewController: UIViewController { return TextFieldsViewController() case .tooltips: return TooltipsViewController() + case .toasts: + return ToastsViewController() case .spinners: return SpinnersViewController() } diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/Contents.json b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/SnackbarDemo.imageset/Contents.json b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/SnackbarDemo.imageset/Contents.json new file mode 100644 index 000000000..f10cd6bfb --- /dev/null +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/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/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png new file mode 100644 index 000000000..85a24cbc9 Binary files /dev/null and b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Media.xcassets/SnackbarDemo.imageset/charcoal-logo.png differ diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Buttons/Buttons.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Buttons/Buttons.swift index eb0df9fcd..1e5dbd845 100644 --- a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Buttons/Buttons.swift +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Buttons/Buttons.swift @@ -19,6 +19,8 @@ final class ButtonsViewController: UIViewController { return view }() + let cellReuseIdentifier = "cell" + private var buttons: [ButtonExample] = [ // Primary ButtonExample(title: "Primary Button M", buttonStyle: CharcoalPrimaryMButton.self, isEnabled: true), diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Toasts/Toasts.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Toasts/Toasts.swift new file mode 100644 index 000000000..95d70378e --- /dev/null +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Toasts/Toasts.swift @@ -0,0 +1,187 @@ +import Charcoal +import UIKit + +enum SnackbarTitles: String, CaseIterable { + case normal = "Normal" + case withAction = "with Action" + case withActionAndThumbnail = "with Action and Thumbnail" + + var text: String { + return "ブックマークしました" + } + + func configCell(cell: UITableViewCell) { + cell.textLabel!.text = "SnackBar" + cell.detailTextLabel?.text = rawValue + } +} + +enum ToastsTitles: String, CaseIterable { + case normal = "Normal" + case normalWithAction = "with Action" + case errorWithAction = "error with Action" + + var text: String { + switch self { + case .normal: + return "Hello World" + case .normalWithAction: + return "Hello World This is a tooltip with mutiple line" + case .errorWithAction: + return "こんにちは This is a tooltip and here is testing it's multiple line feature" + } + } + + func configCell(cell: UITableViewCell) { + cell.textLabel!.text = "Toast" + cell.detailTextLabel?.text = rawValue + } +} + +public final class ToastsViewController: UIViewController { + private lazy var tableView: UITableView = { + let view = UITableView(frame: .zero, style: .insetGrouped) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let cellReuseIdentifier = "cell" + + private enum Sections: Int, CaseIterable { + case snackbars + case toasts + + var title: String { + switch self { + case .toasts: + return "Toasts" + case .snackbars: + return "Snackbars" + } + } + + var items: [any CaseIterable] { + switch self { + case .toasts: + return ToastsTitles.allCases + case .snackbars: + return SnackbarTitles.allCases + } + } + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupUI() + } + + private func setupNavigationBar() { + navigationItem.title = "Charcoal" + } + + private func setupUI() { + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.dataSource = self + tableView.delegate = self + } +} + +extension ToastsViewController: UITableViewDelegate, UITableViewDataSource { + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = Sections.allCases[indexPath.section] + + let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier) as UITableViewCell? ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellReuseIdentifier) + + switch section { + case .toasts: + let titleCase = ToastsTitles.allCases[indexPath.row] + titleCase.configCell(cell: cell) + return cell + case .snackbars: + let titleCase = SnackbarTitles.allCases[indexPath.row] + titleCase.configCell(cell: cell) + return cell + } + } + + public func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + return Sections.allCases[section].items.count + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let section = Sections.allCases[indexPath.section] + + switch section { + case .toasts: + let titleCase = ToastsTitles.allCases[indexPath.row] + + var toastID: CharcoalIdentifiableOverlayView.IDValue + switch titleCase { + case .normal: + toastID = CharcoalToast.show(text: titleCase.text, screenEdge: .top) + case .normalWithAction: + toastID = CharcoalToast.show(text: titleCase.text, screenEdge: .bottom, actionCallback: { + print("Clicked on action") + }) + case .errorWithAction: + toastID = CharcoalToast.show( + text: titleCase.text, + appearance: .error, + screenEdge: .bottom, + actionCallback: { + print("Clicked on action") + } + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + CharcoalToast.dismiss(id: toastID) + } + case .snackbars: + let titleCase = SnackbarTitles.allCases[indexPath.row] + + var toastID: CharcoalIdentifiableOverlayView.IDValue + switch titleCase { + case .normal: + toastID = CharcoalSnackBar.show(text: titleCase.text, screenEdge: .top) + case .withAction: + toastID = CharcoalSnackBar.show(text: titleCase.text, screenEdge: .bottom, action: CharcoalAction(title: "編集", actionCallback: { + print("Tapped 編集") + })) + case .withActionAndThumbnail: + toastID = CharcoalSnackBar.show(text: titleCase.text, thumbnailImage: UIImage(named: "SnackbarDemo", in: Bundle.module, with: nil), screenEdge: .bottom, action: CharcoalAction(title: "編集", actionCallback: { + print("Tapped 編集") + })) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + CharcoalSnackBar.dismiss(id: toastID) + } + } + } + + public func numberOfSections(in tableView: UITableView) -> Int { + return Sections.allCases.count + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return Sections.allCases[section].title + } +} + +@available(iOS 17.0, *) +#Preview { + let viewController = ToastsViewController() + return viewController +} diff --git a/Examples/SwiftUISample/SwiftUISample.xcodeproj/project.pbxproj b/Examples/SwiftUISample/SwiftUISample.xcodeproj/project.pbxproj index 7999be1ed..27cd9b1ac 100644 --- a/Examples/SwiftUISample/SwiftUISample.xcodeproj/project.pbxproj +++ b/Examples/SwiftUISample/SwiftUISample.xcodeproj/project.pbxproj @@ -81,7 +81,7 @@ 24651B2727043658005AD842 /* Sources */, 24651B2827043658005AD842 /* Frameworks */, 24651B2927043658005AD842 /* Resources */, - 242CCCD62771748B00897251 /* SwiftLint */, + 9F0036972C0A0399003BE2C2 /* SwiftLint */, ); buildRules = ( ); @@ -141,7 +141,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 242CCCD62771748B00897251 /* SwiftLint */ = { + 9F0036972C0A0399003BE2C2 /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( diff --git a/Sources/CharcoalShared/Components/CharcoalButtonSizes.swift b/Sources/CharcoalShared/Enums/CharcoalButtonSizes.swift similarity index 100% rename from Sources/CharcoalShared/Components/CharcoalButtonSizes.swift rename to Sources/CharcoalShared/Enums/CharcoalButtonSizes.swift diff --git a/Sources/CharcoalShared/Enums/CharcoalPopupViewEdge.swift b/Sources/CharcoalShared/Enums/CharcoalPopupViewEdge.swift new file mode 100644 index 000000000..48454ad7f --- /dev/null +++ b/Sources/CharcoalShared/Enums/CharcoalPopupViewEdge.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum CharcoalPopupViewEdge { + case top + case bottom + + public var direction: CGFloat { + switch self { + case .top: + return 1 + case .bottom: + return -1 + } + } +} diff --git a/Sources/CharcoalShared/Enums/CharcoalToastAppearance.swift b/Sources/CharcoalShared/Enums/CharcoalToastAppearance.swift new file mode 100644 index 000000000..ef09de9d0 --- /dev/null +++ b/Sources/CharcoalShared/Enums/CharcoalToastAppearance.swift @@ -0,0 +1,13 @@ +public enum CharcoalToastAppearance { + case success + case error + + public var background: ColorAsset.Color { + switch self { + case .success: + return CharcoalAsset.ColorPaletteGenerated.success.color + case .error: + return CharcoalAsset.ColorPaletteGenerated.assertive.color + } + } +} diff --git a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift index fae2d331c..00f470e5b 100644 --- a/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift +++ b/Sources/CharcoalSwiftUI/Components/Overlay/CharcoalPopupProtocol.swift @@ -2,10 +2,7 @@ import SwiftUI typealias CharcoalPopupProtocol = Equatable & Identifiable & View -public enum CharcoalPopupViewEdge { - case top - case bottom - +extension CharcoalPopupViewEdge { var alignment: Alignment { switch self { case .top: diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift index 2a1f73364..1136335aa 100644 --- a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalSnackBar.swift @@ -203,16 +203,6 @@ public extension View { } } -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 diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift index 8c46d4352..21cfc1546 100644 --- a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToast.swift @@ -79,7 +79,7 @@ struct CharcoalToast: CharcoalPopupProtocol, CharcoalToastB .padding(EdgeInsets(top: 8, leading: 24, bottom: 8, trailing: 24)) } .background( - appearance.background + Color(appearance.background) ) .charcoalAnimatableToast( isPresenting: $isPresenting, @@ -110,20 +110,6 @@ struct PopupViewSizeKey: PreferenceKey { 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 diff --git a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift index f883d53fe..e62c2682d 100644 --- a/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift +++ b/Sources/CharcoalSwiftUI/Components/Toast/CharcoalToastDraggableModifier.swift @@ -27,7 +27,7 @@ struct CharcoalToastDraggableModifier: ViewModifier, CharcoalToastDraggable { height: gesture.translation.height ) } else { - // the less the faster resistance + // Rubber band effect let limit: CGFloat = 60 let yOff = gesture.translation.height let dist = sqrt(yOff * yOff) diff --git a/Sources/CharcoalSwiftUI/Extensions/UIColor+Extension.swift b/Sources/CharcoalSwiftUI/Extensions/UIColor+Extension.swift new file mode 100644 index 000000000..17bc16514 --- /dev/null +++ b/Sources/CharcoalSwiftUI/Extensions/UIColor+Extension.swift @@ -0,0 +1,11 @@ +import UIKit + +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)) + } + } +} diff --git a/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift b/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift index ddb7b6acd..81e896b6e 100644 --- a/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift +++ b/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift @@ -130,8 +130,8 @@ extension ChacoalOverlayManager { // MARK: - CharcoalIdentifiableOverlayDelegate extension ChacoalOverlayManager: CharcoalIdentifiableOverlayDelegate { - func overlayViewDidDismiss(_ overlayView: CharcoalIdentifiableOverlayView) { - overlayContainerViews = overlayContainerViews.filter { $0.id != overlayView.id } + func overlayViewDidDismiss(_ overlayID: CharcoalIdentifiableOverlayView.ID) { + overlayContainerViews = overlayContainerViews.filter { $0.id != overlayID } if overlayContainerViews.isEmpty { removeBackground() } diff --git a/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift b/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift index 373f9d8a9..52891ef1b 100644 --- a/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift +++ b/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift @@ -1,7 +1,7 @@ import UIKit protocol CharcoalIdentifiableOverlayDelegate: AnyObject { - func overlayViewDidDismiss(_ overlayView: CharcoalIdentifiableOverlayView) + func overlayViewDidDismiss(_ id: CharcoalIdentifiableOverlayView.IDValue) } public class CharcoalIdentifiableOverlayView: UIView, Identifiable { @@ -59,8 +59,22 @@ public class CharcoalIdentifiableOverlayView: UIView, Identifiable { @objc func dismiss() { dismissAction?() { [weak self] _ in guard let self = self else { return } + self.delegate?.overlayViewDidDismiss(self.id) self.removeFromSuperview() - self.delegate?.overlayViewDidDismiss(self) } } + + /// Make sure that the view is not blocking the touch events of the subview. + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !isUserInteractionEnabled { + for subview in subviews { + let convertedPoint = subview.convert(point, from: self) + if let hitView = subview.hitTest(convertedPoint, with: event) { + return hitView + } + } + return nil + } + return super.hitTest(point, with: event) + } } diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalAction.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalAction.swift new file mode 100644 index 000000000..89383f5bc --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalAction.swift @@ -0,0 +1,11 @@ +public typealias ActionCallback = () -> Void + +public struct CharcoalAction { + let title: String + let actionCallback: ActionCallback + + public init(title: String, actionCallback: @escaping ActionCallback) { + self.title = title + self.actionCallback = actionCallback + } +} diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalRubberAnimator.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalRubberAnimator.swift new file mode 100644 index 000000000..e034fa89e --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalRubberAnimator.swift @@ -0,0 +1,70 @@ +import UIKit + +class CharcoalRubberAnimator: NSObject { + let screenEdge: CharcoalPopupViewEdge + + var dragVelocity: CGPoint = .zero + + var isDragging: Bool = false + + var offset: CGSize = .zero + + var dismiss: (() -> Void)? + + init(screenEdge: CharcoalPopupViewEdge) { + self.screenEdge = screenEdge + super.init() + } + + @objc func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let view = gesture.view else { return } + + let translation = gesture.translation(in: gesture.view) + let velocity = gesture.velocity(in: gesture.view) + let translationInDirection = translation.y * screenEdge.direction + let movingVelocityInDirection = velocity.y * screenEdge.direction + let offsetInDirection = offset.height * screenEdge.direction + + // Rubber band effect + let damping: CGFloat = 0.75 + let initialSpringVelocity: CGFloat = 0.0 + let duration: TimeInterval = 0.65 + + switch gesture.state { + case .began: + isDragging = true + case .changed: + dragVelocity = velocity + if translationInDirection < 0 { + offset = CGSize(width: 0, height: translation.y) + view.transform = CGAffineTransform(translationX: 0, y: translation.y) + } else { + let limit: CGFloat = 60 + let dist = sqrt(translation.y * translation.y) + let factor = 1 / (dist / limit + 1) + offset = CGSize(width: 0, height: translation.y * factor) + view.transform = CGAffineTransform(translationX: 0, y: translation.y * factor) + } + case .ended, .cancelled: + isDragging = false + if offsetInDirection < -50 || movingVelocityInDirection < -100 { + // Dismiss + dismiss?() + } else { + UIView.animate( + withDuration: duration, + delay: 0, + usingSpringWithDamping: damping, + initialSpringVelocity: initialSpringVelocity, + options: [], + animations: { + view.transform = .identity + }, + completion: nil + ) + } + default: + break + } + } +} diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBar.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBar.swift new file mode 100644 index 000000000..84f06e549 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBar.swift @@ -0,0 +1,118 @@ +import UIKit + +public class CharcoalSnackBar {} + +public extension CharcoalSnackBar { + /** + Show a snackbar. + + - Parameters: + - text: The text to be displayed in the snackbar. + - maxWidth: The maximum width of the snackbar. + - thumbnailImage: The thumbnail image to be displayed in the snackbar. + - screenEdge: The edge of the screen where the snackbar will be displayed. + - screenEdgeSpacing: The spacing between the snackbar and the screen edge. + - on: The view on which the snackbar will be displayed. + + # Example # + ```swift + CharcoalSnackBar.show(text: "Hello") + ``` + */ + @discardableResult + static func show( + text: String, + maxWidth: CGFloat = 312, + thumbnailImage: UIImage? = nil, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat = 120, + action: CharcoalAction? = nil, + on: UIView? = nil + ) -> CharcoalIdentifiableOverlayView.IDValue { + let snackbarView = CharcoalSnackBarView(text: text, thumbnailImage: thumbnailImage, maxWidth: maxWidth, action: action) + snackbarView.isUserInteractionEnabled = true + snackbarView.translatesAutoresizingMaskIntoConstraints = false + + let containerView = ChacoalOverlayManager.shared.layout(view: snackbarView, interactionMode: .passThrough, on: on) + containerView.alpha = 1 + containerView.isUserInteractionEnabled = false + containerView.delegate = ChacoalOverlayManager.shared + let containerID = containerView.id + + snackbarView.setupGestureAnimator(screenEdge, gestureDismissCallback: { + ChacoalOverlayManager.shared.dismiss(id: containerID) + }) + + var constraints = [ + snackbarView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor) + ] + + var screenEdgeSpacingConstraint: NSLayoutConstraint + + switch screenEdge { + case .top: + screenEdgeSpacingConstraint = snackbarView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: -screenEdgeSpacing * screenEdge.direction) + case .bottom: + screenEdgeSpacingConstraint = snackbarView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -screenEdgeSpacing * screenEdge.direction) + } + + constraints.append(screenEdgeSpacingConstraint) + + NSLayoutConstraint.activate(constraints) + + containerView.layoutIfNeeded() + + containerView.showAction = { [weak containerView] actionCallback in + screenEdgeSpacingConstraint.constant = screenEdgeSpacingConstraint.constant * -1 + UIView.animate( + withDuration: 0.65, + delay: 0, + usingSpringWithDamping: 0.75, + initialSpringVelocity: 0.0, + options: [], + animations: { + containerView?.layoutIfNeeded() + } + ) { completion in + actionCallback?(completion) + } + } + + containerView.dismissAction = { [weak containerView] actionCallback in + screenEdgeSpacingConstraint.constant = screenEdgeSpacingConstraint.constant * -1 + UIView.animate(withDuration: 0.3, animations: { + containerView?.layoutIfNeeded() + }) { completion in + containerView?.alpha = 0 + actionCallback?(completion) + } + } + + ChacoalOverlayManager.shared.display(view: containerView) + + return containerView.id + } + + /// Dismisses the toast with the given identifier. + static func dismiss(id: CharcoalIdentifiableOverlayView.IDValue) { + ChacoalOverlayManager.shared.dismiss(id: id) + } +} + +@available(iOS 17.0, *) +#Preview() { + let view = UIView() + view.backgroundColor = UIColor.lightGray + + CharcoalSnackBar.show(text: "Hello World", screenEdge: .top) + CharcoalSnackBar.show(text: "こんにちは This is a tooltip and here is testing it's multiple line feature", screenEdge: .top, screenEdgeSpacing: 220) + CharcoalSnackBar.show(text: "こんにちは This is a tooltip and here is testing it's multiple line feature", thumbnailImage: CharcoalAsset.ColorPaletteGenerated.border.color.imageWithColor(width: 64, height: 64), screenEdge: .bottom, action: CharcoalAction(title: "編集", actionCallback: { + print("Tapped 編集") + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + ChacoalOverlayManager.shared.dismiss() + } + + return view +} diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBarView.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBarView.swift new file mode 100644 index 000000000..febf830cc --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalSnackBarView.swift @@ -0,0 +1,247 @@ +import UIKit + +class CharcoalSnackBarView: UIView { + lazy var hStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + lazy var buttonContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var labelContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var label: CharcoalTypography14 = { + let label = CharcoalTypography14() + label.numberOfLines = 1 + label.isBold = true + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.textColor = CharcoalAsset.ColorPaletteGenerated.text1.color + return label + }() + + lazy var thumbnailImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + return imageView + }() + + lazy var actionButton: CharcoalDefaultSButton = { + let button = CharcoalDefaultSButton() + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + let thumbnailImage: UIImage? + + var action: CharcoalAction? + + let text: String + + lazy var capsuleShape: UIView = { + let view = UIView(frame: CGRect.zero) + view.layer.cornerCurve = .continuous + return view + }() + + /// The corner radius of the snackbar + let cornerRadius: CGFloat = 32 + + let borderColor: ColorAsset.Color + + let borderLineWidth: CGFloat = 1 + + /// The max width of the snackbar + let maxWidth: CGFloat + + /// Padding around the bubble + let padding = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + + var gesture: UIGestureRecognizer? + + var animator: CharcoalRubberAnimator? + + init(text: String, thumbnailImage: UIImage? = nil, maxWidth: CGFloat = 312, action: CharcoalAction? = nil) { + self.action = action + self.thumbnailImage = thumbnailImage + self.maxWidth = maxWidth + self.text = text + borderColor = CharcoalAsset.ColorPaletteGenerated.border.color + super.init(frame: .zero) + if let action = action { + actionButton.setTitle(action.title, for: .normal) + } + setupLayer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCapsuleShape() { + addSubview(capsuleShape) + layer.backgroundColor = CharcoalAsset.ColorPaletteGenerated.background1.color.cgColor + layer.borderColor = borderColor.cgColor + layer.borderWidth = borderLineWidth + layer.masksToBounds = true + layer.cornerCurve = .continuous + } + + private func addThumbnailView() { + if let thumbnailImage = thumbnailImage { + thumbnailImageView.image = thumbnailImage + hStackView.addArrangedSubview(thumbnailImageView) + NSLayoutConstraint.activate([ + thumbnailImageView.widthAnchor.constraint(equalToConstant: 64), + thumbnailImageView.heightAnchor.constraint(equalToConstant: 64) + ]) + } + } + + private func addTextLabel() { + hStackView.addArrangedSubview(labelContainer) + + labelContainer.addSubview(label) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor, constant: padding.left), + label.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor, constant: -padding.right), + label.topAnchor.constraint(equalTo: labelContainer.topAnchor, constant: padding.top), + label.bottomAnchor.constraint(equalTo: labelContainer.bottomAnchor, constant: -padding.bottom) + ]) + + label.text = text + label.preferredMaxLayoutWidth = preferredTextMaxWidth + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func addActionButton() { + if let _ = action { + actionButton.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) + actionButton.alpha = 1 + hStackView.addArrangedSubview(buttonContainer) + buttonContainer.addSubview(actionButton) + + NSLayoutConstraint.activate([ + actionButton.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor), + actionButton.trailingAnchor.constraint(equalTo: buttonContainer.trailingAnchor, constant: -padding.right), + actionButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor, constant: padding.top), + actionButton.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: -padding.bottom) + ]) + } + } + + private func setupLayer() { + // Setup Bubble Shape + setupCapsuleShape() + + // Add HStack + addSubview(hStackView) + NSLayoutConstraint.activate([ + hStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + hStackView.topAnchor.constraint(equalTo: topAnchor), + hStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + hStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + hStackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth) + ]) + + // Add thumbnail view + addThumbnailView() + + // Add text label with padding view + addTextLabel() + + // Add action button + addActionButton() + } + + @objc func actionButtonTapped() { + action?.actionCallback() + } + + /// The max width of the text label + var preferredTextMaxWidth: CGFloat { + var width = maxWidth - padding.left - padding.right + + // Check if has thumbnail image + if let _ = thumbnailImage { + width = width - 64 + } + + // Check if has action button + if let _ = action { + width = width - actionButton.intrinsicContentSize.width - padding.right + } + + return width + } + + override func layoutSubviews() { + super.layoutSubviews() + capsuleShape.frame = bounds + layer.cornerRadius = min(cornerRadius, bounds.height / 2.0) + } + + /// Add gesture to this view + func setupGestureAnimator(_ screenEdge: CharcoalPopupViewEdge, gestureDismissCallback: ActionCallback?) { + let gesture = UIPanGestureRecognizer() + let animator = CharcoalRubberAnimator(screenEdge: screenEdge) + animator.dismiss = gestureDismissCallback + gesture.addTarget(animator, action: #selector(CharcoalRubberAnimator.handlePan(_:))) + + addGestureRecognizer(gesture) + + self.animator = animator + self.gesture = gesture + } +} + +@available(iOS 17.0, *) +#Preview(traits: .sizeThatFitsLayout) { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 8.0 + + let snackbar = CharcoalSnackBarView(text: "Hello World") + + let snackbar2 = CharcoalSnackBarView(text: "ブックマークしました", thumbnailImage: CharcoalAsset.ColorPaletteGenerated.border.color.imageWithColor(width: 64, height: 64)) + + let snackbar3 = CharcoalSnackBarView(text: "ブックマークしました", action: CharcoalAction(title: "編集", actionCallback: { + print("編集 taped") + })) + + let snackbar4 = CharcoalSnackBarView( + text: "こんにちは This is a snackbar and here is testing it's multiple line feature", + thumbnailImage: CharcoalAsset.ColorPaletteGenerated.border.color.imageWithColor(width: 64, height: 64), + action: CharcoalAction(title: "編集", actionCallback: { + print("編集 taped") + }) + ) + + let snackbar5 = CharcoalSnackBarView(text: "こんにちは This is a snackbar and here is testing it's multiple line feature") + + stackView.addArrangedSubview(snackbar) + stackView.addArrangedSubview(snackbar2) + stackView.addArrangedSubview(snackbar3) + stackView.addArrangedSubview(snackbar4) + stackView.addArrangedSubview(snackbar5) + + return stackView +} diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalToast.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalToast.swift new file mode 100644 index 000000000..32e8252b6 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalToast.swift @@ -0,0 +1,119 @@ +import UIKit + +public class CharcoalToast {} + +public extension CharcoalToast { + /** + Show a toast. + + - Parameters: + - text: The text to be displayed in the toast. + - maxWidth: The maximum width of the toast. + - appearance: The appearance of the toast. + - screenEdge: The edge of the screen where the toast will be displayed. + - screenEdgeSpacing: The spacing between the toast and the screen edge. + - actionCallback: The callback to be called when the action button is tapped + - on: The view on which the toast will be displayed. + + # Example # + ```swift + CharcoalToast.show(text: "This is a toast") + ``` + */ + @discardableResult + static func show( + text: String, + maxWidth: CGFloat = 312, + appearance: CharcoalToastAppearance = .success, + screenEdge: CharcoalPopupViewEdge = .bottom, + screenEdgeSpacing: CGFloat = 120, + actionCallback: ActionCallback? = nil, + on: UIView? = nil + ) -> CharcoalIdentifiableOverlayView.IDValue { + let toastView = CharcoalToastView(text: text, maxWidth: maxWidth, appearance: appearance, actionCallback: {}) + + toastView.translatesAutoresizingMaskIntoConstraints = false + + let containerView = ChacoalOverlayManager.shared.layout(view: toastView, interactionMode: .passThrough, on: on) + containerView.alpha = 1 + containerView.delegate = ChacoalOverlayManager.shared + + let toastID = containerView.id + + toastView.actionCallback = { + actionCallback?() + CharcoalToast.dismiss(id: toastID) + } + + var constraints = [ + toastView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor) + ] + + var screenEdgeSpacingConstraint: NSLayoutConstraint + + switch screenEdge { + case .top: + screenEdgeSpacingConstraint = toastView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: -screenEdgeSpacing * screenEdge.direction) + case .bottom: + screenEdgeSpacingConstraint = toastView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -screenEdgeSpacing * screenEdge.direction) + } + + constraints.append(screenEdgeSpacingConstraint) + + NSLayoutConstraint.activate(constraints) + + containerView.layoutIfNeeded() + + containerView.showAction = { [weak containerView] actionCallback in + screenEdgeSpacingConstraint.constant = screenEdgeSpacingConstraint.constant * -1 + UIView.animate( + withDuration: 0.65, + delay: 0, + usingSpringWithDamping: 0.75, + initialSpringVelocity: 0.0, + options: [], + animations: { + containerView?.layoutIfNeeded() + } + ) { completion in + actionCallback?(completion) + } + } + + containerView.dismissAction = { [weak containerView] actionCallback in + screenEdgeSpacingConstraint.constant = screenEdgeSpacingConstraint.constant * -1 + UIView.animate(withDuration: 0.3, animations: { + containerView?.layoutIfNeeded() + }) { completion in + containerView?.alpha = 0 + actionCallback?(completion) + } + } + + ChacoalOverlayManager.shared.display(view: containerView) + + return toastID + } + + /// Dismisses the toast with the given identifier. + static func dismiss(id: CharcoalIdentifiableOverlayView.IDValue) { + ChacoalOverlayManager.shared.dismiss(id: id) + } +} + +@available(iOS 17.0, *) +#Preview() { + let view = UIView() + view.backgroundColor = UIColor.lightGray + + DispatchQueue.main.async { + CharcoalToast.show(text: "Hello World", appearance: .success, screenEdge: .top) + CharcoalToast.show(text: "こんにちは This is a tooltip and here is testing it's multiple line feature", appearance: .error, screenEdge: .bottom) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + ChacoalOverlayManager.shared.dismiss() + } + + return view +} diff --git a/Sources/CharcoalUIKit/Components/Toast/CharcoalToastView.swift b/Sources/CharcoalUIKit/Components/Toast/CharcoalToastView.swift new file mode 100644 index 000000000..60efb957e --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Toast/CharcoalToastView.swift @@ -0,0 +1,192 @@ +import UIKit + +class CharcoalToastView: UIView { + lazy var hStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + lazy var buttonContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var labelContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var actionButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = UIColor.white + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + lazy var label: CharcoalTypography14 = { + let label = CharcoalTypography14() + label.numberOfLines = 0 + label.isBold = true + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = CharcoalAsset.ColorPaletteGenerated.text5.color + return label + }() + + let text: String + + /// The appearance of the Toast + let appearance: CharcoalToastAppearance + + lazy var capsuleShape: UIView = { + let view = UIView(frame: CGRect.zero) + view.layer.cornerCurve = .continuous + return view + }() + + /// The corner radius of the toast + let cornerRadius: CGFloat = 32 + + let borderColor: ColorAsset.Color + + let borderLineWidth: CGFloat = 2 + + /// The max width of the toast + let maxWidth: CGFloat + + /// Padding around the bubble + let padding = UIEdgeInsets(top: 8, left: 24, bottom: 8, right: 24) + + var actionCallback: ActionCallback? + + init(text: String, maxWidth: CGFloat = 312, appearance: CharcoalToastAppearance = .success, actionCallback: ActionCallback? = nil) { + self.maxWidth = maxWidth + self.text = text + self.appearance = appearance + self.actionCallback = actionCallback + borderColor = CharcoalAsset.ColorPaletteGenerated.background1.color + super.init(frame: .zero) + + if let _ = actionCallback { + actionButton.setImage(CharcoalAsset.Images.remove16.image.withRenderingMode(.alwaysTemplate), for: .normal) + } + + setupLayer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayer() { + // Setup Bubble Shape + addSubview(capsuleShape) + capsuleShape.backgroundColor = appearance.background + layer.borderColor = borderColor.cgColor + layer.borderWidth = borderLineWidth + layer.masksToBounds = true + layer.cornerCurve = .continuous + + // Add HStack + addSubview(hStackView) + NSLayoutConstraint.activate([ + hStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + hStackView.topAnchor.constraint(equalTo: topAnchor), + hStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + hStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + hStackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth) + ]) + + addTextLabel() + + addActionButton() + } + + private func addTextLabel() { + hStackView.addArrangedSubview(labelContainer) + + labelContainer.addSubview(label) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor, constant: padding.left), + label.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor, constant: -padding.right), + label.topAnchor.constraint(equalTo: labelContainer.topAnchor, constant: padding.top), + label.bottomAnchor.constraint(equalTo: labelContainer.bottomAnchor, constant: -padding.bottom) + ]) + + label.text = text + label.preferredMaxLayoutWidth = preferredTextMaxWidth + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func addActionButton() { + if let _ = actionCallback { + actionButton.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) + actionButton.alpha = 1 + hStackView.addArrangedSubview(buttonContainer) + buttonContainer.addSubview(actionButton) + + NSLayoutConstraint.activate([ + actionButton.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor), + actionButton.trailingAnchor.constraint(equalTo: buttonContainer.trailingAnchor, constant: -padding.right), + actionButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor, constant: padding.top), + actionButton.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: -padding.bottom) + ]) + } + } + + @objc func actionButtonTapped() { + actionCallback?() + } + + /// The max width of the text label + var preferredTextMaxWidth: CGFloat { + var width = maxWidth - padding.left - padding.right + + // Check if has action button + if let _ = actionCallback { + width = width - actionButton.intrinsicContentSize.width - padding.right + } + + return width + } + + override func layoutSubviews() { + super.layoutSubviews() + capsuleShape.frame = bounds + layer.cornerRadius = min(cornerRadius, bounds.height / 2.0) + } +} + +@available(iOS 17.0, *) +#Preview(traits: .sizeThatFitsLayout) { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 8.0 + + let toast = CharcoalToastView(text: "Hello World") + + let toast2 = CharcoalToastView(text: "Hello World This is a toast", appearance: .error) + + let toast3 = CharcoalToastView(text: "here is testing it's multiple line feature") + + let toast4 = CharcoalToastView(text: "こんにちは This is a toast and here is testing it's multiple line feature") { + print("Button Tapped") + } + + stackView.addArrangedSubview(toast) + stackView.addArrangedSubview(toast2) + stackView.addArrangedSubview(toast3) + stackView.addArrangedSubview(toast4) + + return stackView +} diff --git a/Sources/CharcoalUIKit/Extensions/StringExtension.swift b/Sources/CharcoalUIKit/Extensions/StringExtension.swift index 6662659e3..e0e643dc9 100644 --- a/Sources/CharcoalUIKit/Extensions/StringExtension.swift +++ b/Sources/CharcoalUIKit/Extensions/StringExtension.swift @@ -1,6 +1,7 @@ import UIKit extension String { + // swiftformat:disable redundantSelf func calculateFrame(font: UIFont, maxWidth: CGFloat) -> CGSize { let size = CGSize(width: maxWidth, height: .greatestFiniteMagnitude) let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] diff --git a/Sources/CharcoalUIKit/Extensions/UIColorExtension.swift b/Sources/CharcoalUIKit/Extensions/UIColorExtension.swift index 7cc7aab11..b894044b2 100644 --- a/Sources/CharcoalUIKit/Extensions/UIColorExtension.swift +++ b/Sources/CharcoalUIKit/Extensions/UIColorExtension.swift @@ -35,4 +35,12 @@ extension UIColor { return UIColor(red: r, green: g, blue: b, alpha: a) }() } + + 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)) + } + } }