diff --git a/Sources/ToastSwift/Extension/UIColor+Extension.swift b/Sources/ToastSwift/Extension/UIColor+Extension.swift new file mode 100644 index 0000000..7bd86f4 --- /dev/null +++ b/Sources/ToastSwift/Extension/UIColor+Extension.swift @@ -0,0 +1,24 @@ +// +// UIColor+Extension.swift +// ToastSwift +// +// Created by Jayvee on 7/11/25. +// + +import UIKit + +public extension UIColor { + static func colorWithHexString(_ hex: String) -> UIColor { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&rgb) + + let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + let b = CGFloat(rgb & 0x0000FF) / 255.0 + + return UIColor(red: r, green: g, blue: b, alpha: 1.0) + } +} diff --git a/Sources/ToastSwift/ToastSwift.swift b/Sources/ToastSwift/ToastSwift.swift index 37ad4c1..1e8d90d 100644 --- a/Sources/ToastSwift/ToastSwift.swift +++ b/Sources/ToastSwift/ToastSwift.swift @@ -9,32 +9,10 @@ public class Delay: NSObject { @objc(Long) public static let long: TimeInterval = 3.5 } -open class ToastSwift: Operation { - - // MARK: Properties - - @MainActor - @objc public var text: String? { - get { return self.view.text } - set { return self.view.text = newValue } - } - - @MainActor - @objc public var attributedText: NSAttributedString? { - get { return self.view.attributedText } - set { return self.view.attributedText = newValue } - } - - @MainActor - public var hideActionButton: Bool { - get { return self.view.hideActionButton ?? true } - set { return self.view.hideActionButton = newValue } - } - - @objc public var delay: TimeInterval = 0 - @objc public var duration: TimeInterval = 2.0 - @objc public var action: (() -> Void)? +open class ToastSwift: Operation, @unchecked Sendable { + //MARK: - Properties + private var _executing = false override public var isExecuting: Bool { get { return _executing } @@ -55,33 +33,20 @@ open class ToastSwift: Operation { } } - // MARK: User Interface + //MARK: - User Interface @MainActor @objc public var view: ToastView = ToastView() - - // MARK: Initializing - - @MainActor - @objc public init(text: String? = nil, backgroundColor: UIColor = UIColor(red: 0.24, green: 0.24, blue: 0.24, alpha: 1.00), willHideActionButton: Bool = true, delay: TimeInterval = 0, duration: TimeInterval = Delay.short) { - self.delay = delay - self.duration = duration - self.view.backgroundColor = backgroundColor - super.init() - self.text = text - } - + // MARK: - Initialization + @MainActor - @objc public init(attributedText: NSAttributedString?, backgroundColor: UIColor = UIColor(red: 0.24, green: 0.24, blue: 0.24, alpha: 1.00), willHideActionButton: Bool = true, delay: TimeInterval = 0, duration: TimeInterval = Delay.short) { - self.delay = delay - self.duration = duration - self.view.backgroundColor = backgroundColor + @objc public init(with attributes: ToastAttributes, onButtonTap buttonAction: (() -> Void)? = nil) { super.init() - self.attributedText = attributedText + view.bind(with: attributes) } - // MARK: Actions + //MARK: - Actions @MainActor @objc public func show() { ToastManager.default.add(self) @@ -99,64 +64,80 @@ open class ToastSwift: Operation { override open func start() { let isRunnable = !self.isFinished && !self.isCancelled && !self.isExecuting + guard isRunnable else { return } guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.start() + guard let self = self else { return } + self.start() } + return } + main() } override open func main() { - self.isExecuting = true - - DispatchQueue.main.async { - self.view.setNeedsLayout() - self.view.alpha = 0 - ToastWindow.shared.addSubview(self.view) - - UIView.animate( - withDuration: 0.5, - delay: self.delay, - options: .beginFromCurrentState, - animations: { - self.view.alpha = 1 - }, - completion: { completed in - if ToastManager.default.isSupportAccessibility { - #if swift(>=4.2) - UIAccessibility.post(notification: .announcement, argument: self.view.text) - #else - UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, self.view.text) - #endif - } + self.isExecuting = true + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.view.setNeedsLayout() + self.view.alpha = 0 + ToastWindow.shared.addSubview(self.view) + UIView.animate( - withDuration: self.duration, - animations: { - self.view.alpha = 1.0001 - }, - completion: { completed in - self.finish() - UIView.animate( - withDuration: 0.5, - animations: { - self.view.alpha = 0 - }, - completion: { completed in - self.view.removeFromSuperview() - } - ) - } + withDuration: 0.5, + delay: self.view.attributes.delay, + options: .beginFromCurrentState, + animations: { + self.view.alpha = 1 + }, + completion: { completed in + if ToastManager.default.isSupportAccessibility { + var message: String = "" + + if self.view.attributes.shouldUseAttributedText { + if let val = self.view.attributes.messageLabelAttributedText?.string { + message = val + } + } else { + message = self.view.attributes.messageLabelText + } + +#if swift(>=4.2) + UIAccessibility.post(notification: .announcement, argument: message) +#else + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, message) +#endif + } + UIView.animate( + withDuration: self.view.attributes.duration, + animations: { + self.view.alpha = 1.0001 + }, + completion: { completed in + self.finish() + UIView.animate( + withDuration: 0.5, + animations: { + self.view.alpha = 0 + }, + completion: { completed in + self.view.removeFromSuperview() + } + ) + } + ) + } ) - } - ) - } + } } func finish() { - self.isExecuting = false - self.isFinished = true + self.isExecuting = false + self.isFinished = true } } diff --git a/Sources/ToastSwift/ToastView.swift b/Sources/ToastSwift/ToastView.swift index c295fba..2953831 100644 --- a/Sources/ToastSwift/ToastView.swift +++ b/Sources/ToastSwift/ToastView.swift @@ -8,261 +8,269 @@ import UIKit open class ToastView: UIView { - - // MARK: - Properties - - open var text: String? { - get { return self.textLabel.text } - set { self.textLabel.text = newValue } - } - - open var attributedText: NSAttributedString? { - get { return self.textLabel.attributedText } - set { self.textLabel.attributedText = newValue } - } - - open var hideActionButton: Bool? { - get { return self.actionTextLabel.isHidden } - set { self.actionTextLabel.isHidden = newValue ?? true } - } - - // MARK: - Appearance - - override open dynamic var backgroundColor: UIColor? { - get { return self.backgroundView.backgroundColor } - set { self.backgroundView.backgroundColor = newValue } - } - - @objc open dynamic var cornerRadius: CGFloat { - get { return self.backgroundView.layer.cornerRadius } - set { self.backgroundView.layer.cornerRadius = newValue } - } - - @objc public var contentInsets = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15) - - @objc open dynamic var textColor: UIColor { - get { return self.textLabel.textColor } - set { self.textLabel.textColor = newValue } - } - - @objc open dynamic var font: UIFont { - get { return self.textLabel.font } - set { self.textLabel.font = newValue } - } + + //MARK: - Properties - @objc open dynamic var bottomOffsetPortrait: CGFloat = { + private let bottomOffsetPortrait: CGFloat = { switch UIDevice.current.userInterfaceIdiom { - case .phone: return 39 // Figma design + case .phone: return 39 case .pad: return 60 case .tv: return 90 case .carPlay: return 30 case .mac: return 60 case .vision: return 60 - // default values case .unspecified: fallthrough @unknown default: return 3 } }() - - @objc open dynamic var bottomOffsetLandscape: CGFloat = { + + private let bottomOffsetLandscape: CGFloat = { switch UIDevice.current.userInterfaceIdiom { - // specific values - case .phone: return 39 // Figma design + case .phone: return 39 case .pad: return 40 case .tv: return 60 case .carPlay: return 20 case .mac: return 40 case .vision: return 40 - // default values case .unspecified: fallthrough @unknown default: return 20 } }() - @objc open dynamic var useSafeAreaForBottomOffset: Bool = false - - @objc open dynamic var maxWidthRatio: CGFloat = (405 / 430) //Figma UI design width / canvas width - - @objc open dynamic var shadowPath: CGPath? { - get { return self.layer.shadowPath } - set { self.layer.shadowPath = newValue } - } - - @objc open dynamic var shadowColor: UIColor? { - get { return self.layer.shadowColor.flatMap { UIColor(cgColor: $0) } } - set { self.layer.shadowColor = newValue?.cgColor } - } + private lazy var containerSize: CGSize = { + let containerSize = UIScreen.main.bounds.size + let constraintSize = CGSize( + width: containerSize.width * attributes.maxWidthRatio, + height: CGFloat.greatestFiniteMagnitude + ) + + return containerSize + }() - @objc open dynamic var shadowOpacity: Float { - get { return self.layer.shadowOpacity } - set { self.layer.shadowOpacity = newValue } - } + public var onButtonTap: (() -> Void)? - @objc open dynamic var shadowOffset: CGSize { - get { return self.layer.shadowOffset } - set { self.layer.shadowOffset = newValue } - } + private var _attributes: ToastAttributes = ToastAttributes() - @objc open dynamic var shadowRadius: CGFloat { - get { return self.layer.shadowRadius } - set { self.layer.shadowRadius = newValue } + open var attributes: ToastAttributes { + get { _attributes } + set { _attributes = newValue } } - - // MARK: - User Interface + //MARK: - User Interface private lazy var backgroundView: UIView = { - let `self` = UIView() - self.layer.cornerRadius = 16 - self.layer.masksToBounds = false - self.layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath - self.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 1).cgColor - self.layer.shadowOpacity = 1 - self.layer.shadowRadius = 16 - self.layer.shadowOffset = CGSize(width: 1, height: 1) - return self + let view = UIView() + view.layer.masksToBounds = false + view.layer.cornerRadius = 16 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 1 + view.layer.shadowRadius = 16 + view.layer.shadowOffset = CGSize(width: 1, height: 1) + return view }() - - private lazy var textLabel: UILabel = { - let `self` = UILabel() - self.textColor = .black - self.backgroundColor = .clear - self.font = UIFont.systemFont(ofSize: 13.5) - self.numberOfLines = 0 - self.textAlignment = .left - return self + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = .zero + label.textAlignment = .left + return label }() - - private lazy var actionTextLabel: UILabel = { - let `self` = UILabel() - self.textColor = .white - self.backgroundColor = .clear - self.font = UIFont.systemFont(ofSize: 13.5) - self.numberOfLines = 0 - self.textAlignment = .center - return self + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.numberOfLines = .zero + label.textAlignment = .left + return label }() - - private lazy var columnStackView: UIStackView = { - let `self` = UIStackView() - self.axis = .horizontal - self.spacing = 16 - self.distribution = .fill - return self + + public lazy var actionButton: UIButton = { [unowned self] in + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + return button + }() + + private lazy var verticalStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = .zero + stackView.distribution = .fill + return stackView + }() + + private lazy var horizontalStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = .zero + stackView.distribution = .fill + return stackView }() + //MARK: - Initialization - //MARK: Initializing - public convenience init() { self.init(frame: .zero) - self.isUserInteractionEnabled = true - self.addSubview(self.backgroundView) - self.addSubview(self.columnStackView) - self.columnStackView.addArrangedSubview(self.textLabel) -// self.columnStackView.addArrangedSubview(self.actionTextLabel) - - let actionLabelAttribute: [NSAttributedString.Key : Any] = [ - NSAttributedString.Key.font: UIFont(name: "SFProDisplay-Medium", size: 13.5), - NSAttributedString.Key.kern: 0.2, - NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue] - - self.actionTextLabel.attributedText = NSAttributedString(string: "View", attributes: actionLabelAttribute) - - self.actionTextLabel.isHidden = self.hideActionButton ?? true + + setupSubviews() } required convenience public init?(coder: NSCoder) { self.init() } - // MARK: Layout + //MARK: - Layout override open func layoutSubviews() { - super.layoutSubviews() -// let containerSize = ToastWindow.shared.frame.size + super.layoutSubviews() + + layoutContent() + updateFrame() + } +} + +//MARK: - Setup + +private extension ToastView { + func setupSubviews() { + isUserInteractionEnabled = true + + addSubview(backgroundView) + addSubview(horizontalStackView) + + verticalStackView.addArrangedSubview(titleLabel) + verticalStackView.addArrangedSubview(messageLabel) + + horizontalStackView.addArrangedSubview(verticalStackView) + horizontalStackView.addArrangedSubview(actionButton) + } + + func layoutContent() { let containerSize = UIScreen.main.bounds.size let constraintSize = CGSize( - width: containerSize.width * maxWidthRatio, + width: containerSize.width * attributes.maxWidthRatio, height: CGFloat.greatestFiniteMagnitude ) - let textLabelSize = self.textLabel.sizeThatFits(constraintSize) - self.textLabel.frame = CGRect( - x: self.contentInsets.left, - y: self.contentInsets.top, - width: textLabelSize.width - self.contentInsets.left - self.contentInsets.right, + let textLabelSize = titleLabel.sizeThatFits(constraintSize) + titleLabel.frame = CGRect( + x: .zero, + y: attributes.contentInsets.top, + width: textLabelSize.width - attributes.contentInsets.left - attributes.contentInsets.right, height: textLabelSize.height ) - let actionTextLabelSize = self.actionTextLabel.sizeThatFits(constraintSize) - self.actionTextLabel.frame = CGRect( - x: 0, - y: 0, - width: actionTextLabelSize.width, - height: actionTextLabelSize.height + let messageLabelSize = messageLabel.sizeThatFits(constraintSize) + messageLabel.frame = CGRect( + x: .zero, + y: attributes.contentInsets.top, + width: messageLabelSize.width - attributes.contentInsets.left - attributes.contentInsets.right, + height: messageLabelSize.height ) - self.columnStackView.frame = CGRect( - x: self.contentInsets.left, - y: self.contentInsets.top, - width: constraintSize.width - self.contentInsets.left - self.contentInsets.right, - height: textLabelSize.height + verticalStackView.frame = CGRect( + x: attributes.contentInsets.left, + y: attributes.contentInsets.top, + width: constraintSize.width - attributes.contentInsets.left - attributes.contentInsets.right, + height: textLabelSize.height + messageLabelSize.height ) - self.backgroundView.frame = CGRect( - x: 0, - y: 0, + horizontalStackView.frame = CGRect( + x: attributes.contentInsets.left, + y: attributes.contentInsets.top, + width: constraintSize.width - attributes.contentInsets.left - attributes.contentInsets.right, + height: verticalStackView.frame.size.height + ) + + backgroundView.frame = CGRect( + x: .zero, + y: .zero, width: constraintSize.width, - height: self.columnStackView.frame.height + self.contentInsets.top + self.contentInsets.bottom + height: horizontalStackView.frame.height + attributes.contentInsets.top + attributes.contentInsets.bottom ) + } + + func updateFrame () { + var x: CGFloat = .zero + var y: CGFloat = .zero + var width: CGFloat = .zero + var height: CGFloat = .zero - self.textLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - self.actionTextLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - NSLayoutConstraint.activate([ - self.actionTextLabel.widthAnchor.constraint(equalToConstant: self.actionTextLabel.frame.width) - ]) + var orientation: UIInterfaceOrientation = .portrait - var x: CGFloat - var y: CGFloat - var width: CGFloat - var height: CGFloat + if #available(iOS 13.0, *) { + if let interfaceOrientation = window?.windowScene?.interfaceOrientation { + orientation = interfaceOrientation + } + } else { + orientation = UIApplication.shared.statusBarOrientation + } - let orientation = UIApplication.shared.statusBarOrientation if orientation.isPortrait || !ToastWindow.shared.shouldRotateManually { width = containerSize.width height = containerSize.height - y = self.bottomOffsetPortrait + y = attributes.bottomOffsetPortrait > .zero ? attributes.bottomOffsetPortrait : bottomOffsetPortrait } else { width = containerSize.height height = containerSize.width - y = self.bottomOffsetLandscape + y = attributes.bottomOffsetLandscape > .zero ? attributes.bottomOffsetLandscape : bottomOffsetLandscape } - if #available(iOS 11.0, *), useSafeAreaForBottomOffset { + + if #available(iOS 11.0, *), attributes.useSafeAreaForBottomOffset { y += ToastWindow.shared.safeAreaInsets.bottom } - let backgroundViewSize = self.backgroundView.frame.size + let backgroundViewSize = backgroundView.frame.size x = (width - backgroundViewSize.width) * 0.5 y = height - (backgroundViewSize.height + y) - self.frame = CGRect( + + frame = CGRect( x: x, y: y, width: backgroundViewSize.width, height: backgroundViewSize.height ) } +} + +//MARK: - Binding - override open func hitTest(_ point: CGPoint, with event: UIEvent!) -> UIView? { - if let superview = self.superview { - let pointInWindow = self.convert(point, to: superview) - let contains = self.frame.contains(pointInWindow) - if contains && self.isUserInteractionEnabled { - return self +extension ToastView { + func bind(with attributesParam: ToastAttributes) { + attributes = attributesParam + + backgroundView.backgroundColor = attributesParam.backgroundColor + backgroundView.layer.cornerRadius = attributesParam.cornerRadius + + if attributes.shouldUseAttributedText { + titleLabel.attributedText = attributesParam.titleLabelAttributedText + messageLabel.attributedText = attributesParam.messageLabelAttributedText + } else { + titleLabel.text = attributesParam.titleLabelText + titleLabel.font = attributesParam.titleLabelFont + titleLabel.textColor = attributesParam.foregroundColor + + messageLabel.text = attributesParam.messageLabelText + messageLabel.font = attributesParam.messageLabelFont + messageLabel.textColor = attributesParam.foregroundColor } - } - return nil + + if attributesParam.shouldUseAttributedButtonTitle { + actionButton.setAttributedTitle(attributesParam.buttonTitleAttributedText, for: .normal) + } else { + actionButton.setTitle(attributesParam.buttonTitle, for: .normal) + } + } +} + +//MARK: - Action + +extension ToastView { + @objc private func buttonTapped() { + onButtonTap?() } } + + diff --git a/Sources/ToastSwift/Util/ToastAttributes.swift b/Sources/ToastSwift/Util/ToastAttributes.swift new file mode 100644 index 0000000..b4ecd0d --- /dev/null +++ b/Sources/ToastSwift/Util/ToastAttributes.swift @@ -0,0 +1,113 @@ +// +// ToastAttributes.swift +// ToastSwift +// +// Created by Jayvee on 7/11/25. +// + +import UIKit + +@objc public class ToastAttributes: NSObject { + @objc public var titleLabelText: String? + @objc public var messageLabelText: String + @objc public var buttonTitle: String + + @objc public var titleLabelFont: UIFont + @objc public var messageLabelFont: UIFont + @objc public var buttonTitleFont: UIFont + + @objc public var foregroundColor: UIColor + @objc public var backgroundColor: UIColor + + @objc public var cornerRadius: CGFloat + @objc public var contentInsets: UIEdgeInsets + + @objc public var showButton: Bool + @objc public var shouldUseAttributedText: Bool + @objc public var shouldUseAttributedButtonTitle: Bool + + @objc public var useSafeAreaForBottomOffset: Bool + @objc public var maxWidthRatio: CGFloat + + @objc public var bottomOffsetPortrait: CGFloat + @objc public var bottomOffsetLandscape: CGFloat + + @objc public var delay: TimeInterval + @objc public var duration: TimeInterval + + @objc public var titleLabelAttributedText: NSAttributedString? { + guard shouldUseAttributedText, let title = titleLabelText else { return nil } + return NSAttributedString( + string: title, + attributes: [ + .font: titleLabelFont, + .foregroundColor: foregroundColor + ] + ) + } + + @objc public var messageLabelAttributedText: NSAttributedString? { + guard shouldUseAttributedText, !messageLabelText.isEmpty else { return nil } + return NSAttributedString( + string: messageLabelText, + attributes: [ + .font: messageLabelFont, + .foregroundColor: foregroundColor + ] + ) + } + + @objc public var buttonTitleAttributedText: NSAttributedString? { + guard shouldUseAttributedButtonTitle, !buttonTitle.isEmpty else { return nil } + return NSAttributedString( + string: buttonTitle, + attributes: [ + .font: buttonTitleFont, + .foregroundColor: foregroundColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + ) + } + + @objc public init( + titleLabelText: String? = "Title here", + messageLabelText: String = "message here", + buttonTitle: String = "Button", + titleLabelFont: UIFont = .systemFont(ofSize: 15), + messageLabelFont: UIFont = .systemFont(ofSize: 13), + buttonTitleFont: UIFont = .systemFont(ofSize: 13), + foregroundColor: UIColor = .white, + backgroundColor: UIColor = UIColor.colorWithHexString("#3C3C3C"), + cornerRadius: CGFloat = 16.0, + contentInsets: UIEdgeInsets = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15), + showButton: Bool = false, + shouldUseAttributedText: Bool = true, + shouldUseAttributedButtonTitle: Bool = true, + useSafeAreaForBottomOffset: Bool = false, + maxWidthRatio: CGFloat = 405 / 430, + bottomOffsetPortrait: CGFloat = .zero, + bottomOffsetLandscape: CGFloat = .zero, + delay: TimeInterval = .zero, + duration: TimeInterval = Delay.short + ) { + self.titleLabelText = titleLabelText + self.messageLabelText = messageLabelText + self.buttonTitle = buttonTitle + self.titleLabelFont = titleLabelFont + self.messageLabelFont = messageLabelFont + self.buttonTitleFont = buttonTitleFont + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius + self.contentInsets = contentInsets + self.showButton = showButton + self.shouldUseAttributedText = shouldUseAttributedText + self.shouldUseAttributedButtonTitle = shouldUseAttributedButtonTitle + self.useSafeAreaForBottomOffset = useSafeAreaForBottomOffset + self.maxWidthRatio = maxWidthRatio + self.bottomOffsetPortrait = bottomOffsetPortrait + self.bottomOffsetLandscape = bottomOffsetLandscape + self.delay = delay + self.duration = duration + } +}