// // Notification.swift // VDS // // Created by Nadigadda, Sumanth on 14/03/23. // import Foundation import UIKit import VDSCoreTokens import Combine /// Notifications are prominent, attention-getting banners that provide information /// in context. There are four types: information, success, warning and error; each /// with different color and content. They may be screen-specific, flow-specific or /// experience-wide. @objcMembers @objc(VDSNotification) open class Notification: View, ParentViewProtocol { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- /// Enum used to describe the type of Notification. public enum Style: String, CaseIterable { case info, success, warning, error var iconName: Icon.Name { switch self { case .info: .infoBold case .success: .checkmarkAltBold case .warning: .warningBold case .error: .errorBold } } var accessibleText: String { switch self { case .info: "Information Message" case .success: "Success Message" case .warning: "Warning Message" case .error: "Catastrophic Warning Alert" } } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var mainStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .horizontal $0.spacing = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X } private var labelsView = UIStackView().with { $0.spacing = VDSLayout.space1X $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .fill $0.distribution = .equalSpacing $0.axis = .vertical } private var labelButtonView = View().with { $0.translatesAutoresizingMaskIntoConstraints = false } private var labelButtonViewSpacing: CGFloat { UIDevice.isIPad ? 20 : 16 } internal var onCloseSubscriber: AnyCancellable? private var leadingConstraint: NSLayoutConstraint? private var trailingConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var children: [any ViewProtocol] { [typeIcon, closeButton, titleLabel, subTitleLabel, primaryButton, secondaryButton] } /// Icon used for denoting type. open var typeIcon = Icon().with { $0.name = .infoBold $0.size = UIDevice.isIPad ? .medium : .small $0.accessibilityTraits.remove(.image) } /// Icon used for the close. open var closeButton = Icon().with { $0.name = .close $0.size = UIDevice.isIPad ? .medium : .small } /// Label used to show title. open var titleLabel = Label().with { $0.textStyle = UIDevice.isIPad ? .boldBodyLarge : .boldBodySmall } /// Label used to show subTitle. open var subTitleLabel = Label().with { $0.textStyle = UIDevice.isIPad ? .bodyLarge : .bodySmall } /// ButtonGroup to house the 2 optional buttons, primaryButton and secondaryButotn open var buttonGroup = ButtonGroup().with { $0.alignment = .left } /// Text that will go into the titleLabel. open var title: String = "" { didSet { setNeedsUpdate()}} /// Text that will go into the subTitleLabel. open var subTitle: String? { didSet { setNeedsUpdate()}} /// Button model representing the primaryButton. open var primaryButtonModel: ButtonModel? { didSet { setNeedsUpdate()}} /// Button used for the primary action. open var primaryButton = Button().with { $0.size = .small $0.use = .secondary } /// Button model representing the secondaryButton. open var secondaryButtonModel: ButtonModel? { didSet { setNeedsUpdate()}} /// Button used for the secondary action. open var secondaryButton = Button().with { $0.size = .small $0.use = .secondary } /// Completion Block for a close click event. open var onCloseClick: ((Notification)->())? { didSet { if let onCloseClick { onCloseSubscriber = closeButton .publisher(for: UITapGestureRecognizer()) .sink { _ in onCloseClick(self) } } else { onCloseSubscriber = nil } } } /// If true, will hide the close button. open var hideCloseButton: Bool = false { didSet { setNeedsUpdate()}} /// Add this attribute determine your type of Notification. open var style: Style = .info { didSet { setNeedsUpdate()}} //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var backgroundColorConfiguration: AnyColorable = { let config = KeyedColorConfiguration(keyPath: \.style) config.setSurfaceColors(VDSColor.feedbackInformationBackgroundOnlight, VDSColor.feedbackInformationBackgroundOndark, forKey: .info) config.setSurfaceColors(VDSColor.feedbackWarningBackgroundOnlight, VDSColor.feedbackWarningBackgroundOndark, forKey: .warning) config.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forKey: .success) config.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forKey: .error) return config.eraseToAnyColorable() }() private var textColorConfiguration = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: true) } private var edgeSpacing: CGFloat { return UIDevice.isIPad ? VDSLayout.space5X : VDSLayout.space4X } private var minViewHeight: CGFloat { return UIDevice.isIPad ? VDSLayout.space16X : VDSLayout.space12X } private var minContentHeight: CGFloat { return UIDevice.isIPad ? VDSLayout.space5X : VDSLayout.space4X } private var minViewWidth: CGFloat { return 288 } private var labelViewBottomConstraint: NSLayoutConstraint? private var labelViewAndButtonViewConstraint: NSLayoutConstraint? private var buttonViewTopConstraint: NSLayoutConstraint? private var typeIconWidthConstraint: NSLayoutConstraint? private var closeIconWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() let layoutGuide = UILayoutGuide() addLayoutGuide(layoutGuide) layoutGuide .pinTop(0) .pinLeading(0) .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) addSubview(mainStackView) mainStackView.pin(layoutGuide, with: .uniform(edgeSpacing)) NSLayoutConstraint.activate([ layoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: minViewHeight), mainStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: minContentHeight), layoutGuide.widthAnchor.constraint(greaterThanOrEqualToConstant: minViewWidth) ]) labelButtonView.addSubview(labelsView) labelsView .pinTop() .pinLeading() labelsView.widthAnchor.constraint(equalTo: labelButtonView.widthAnchor, multiplier: 1.0).activate() labelViewBottomConstraint = labelButtonView.bottomAnchor.constraint(equalTo: labelsView.bottomAnchor) labelButtonView.addSubview(buttonGroup) buttonGroup .pinTrailing() labelButtonView.bottomAnchor.constraint(equalTo: buttonGroup.bottomAnchor).activate() labelViewAndButtonViewConstraint = buttonGroup.topAnchor.constraint(equalTo: labelsView.bottomAnchor, constant: VDSLayout.space3X) buttonGroup.widthAnchor.constraint(equalTo: labelsView.widthAnchor).activate() mainStackView.addArrangedSubview(typeIcon) mainStackView.addArrangedSubview(labelButtonView) mainStackView.addArrangedSubview(closeButton) typeIconWidthConstraint = typeIcon.width(constant: typeIcon.size.dimensions.width) closeIconWidthConstraint = closeButton.width(constant: closeButton.size.dimensions.width) //labels titleLabel.textColorConfiguration = textColorConfiguration.eraseToAnyColorable() subTitleLabel.textColorConfiguration = textColorConfiguration.eraseToAnyColorable() //TODO: Need to add setup animation for displaying the Notification view for iOS. isAccessibilityElement = false accessibilityElements = [closeButton, typeIcon, titleLabel, subTitleLabel, buttonGroup] closeButton.accessibilityTraits = [.button] closeButton.accessibilityLabel = "Close Notification" } open override func setDefaults() { super.setDefaults() titleLabel.text = "" titleLabel.textStyle = UIDevice.isIPad ? .boldBodyLarge : .boldBodySmall subTitleLabel.textStyle = UIDevice.isIPad ? .bodyLarge : .bodySmall buttonGroup.alignment = .left primaryButtonModel = nil secondaryButtonModel = nil style = .info typeIcon.size = UIDevice.isIPad ? .medium : .small typeIcon.name = .infoBold onCloseClick = nil closeButton.size = UIDevice.isIPad ? .medium : .small closeButton.name = .close hideCloseButton = false typeIcon.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return style.accessibleText } } /// Resets to default settings. open override func reset() { titleLabel.reset() subTitleLabel.reset() buttonGroup.reset() super.reset() } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() backgroundColor = backgroundColorConfiguration.getColor(self) updateIcons() updateLabels() updateButtons() setConstraints() } /// Override to check the screen width to determine cornerRadius open override func layoutSubviews() { super.layoutSubviews() layer.cornerRadius = UIScreen.main.bounds.width == bounds.width ? 0 : 4.0 } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateIcons() { let iconColor = surface == .dark ? VDSColor.paletteWhite : VDSColor.paletteBlack typeIcon.name = style.iconName typeIcon.color = iconColor closeButton.color = iconColor closeButton.isHidden = hideCloseButton } private func updateLabels() { titleLabel.surface = surface subTitleLabel.surface = surface if !title.isEmpty { titleLabel.text = title labelsView.addArrangedSubview(titleLabel) } else { titleLabel.removeFromSuperview() } if let subTitle { subTitleLabel.text = subTitle labelsView.addArrangedSubview(subTitleLabel) } else { subTitleLabel.removeFromSuperview() } } private func updateButtons() { var buttons: [Button] = [] if let primaryButtonModel { primaryButton.text = primaryButtonModel.text primaryButton.surface = surface primaryButton.onClick = primaryButtonModel.onClick buttons.append(primaryButton) } if let secondaryButtonModel { secondaryButton.text = secondaryButtonModel.text secondaryButton.surface = surface secondaryButton.onClick = secondaryButtonModel.onClick buttons.append(secondaryButton) } if buttons.isEmpty { buttonGroup.isHidden = true buttonGroup.buttons.removeAll() } else { labelsView.setCustomSpacing(VDSLayout.space3X, after: subTitleLabel) buttonGroup.buttons = buttons buttonGroup.isHidden = false } } private func setConstraints() { labelViewAndButtonViewConstraint?.deactivate() labelViewBottomConstraint?.deactivate() labelViewAndButtonViewConstraint?.isActive = !buttonGroup.buttons.isEmpty labelViewBottomConstraint?.isActive = buttonGroup.buttons.isEmpty typeIconWidthConstraint?.constant = typeIcon.size.dimensions.width closeIconWidthConstraint?.constant = closeButton.size.dimensions.width } }