Remove legacy notification view (#980)
* Transition UIKit Demo Controller to use SwiftUI Notification
* Let UIKit size itself independently
* Remove UIKit implementation and resolve comments
* Move xcflielist and update podspec
* Move xcfilelist into Notification folder
* Move override tokens to modifiers, reorder files to alphabetize, and add comments
(cherry picked from commit 1d9b1c7dcd
)
This commit is contained in:
Родитель
13d4cc35b4
Коммит
9f30175d1a
|
@ -191,9 +191,8 @@ fi', :execution_position => :before_compile }
|
|||
notification_ios.platform = :ios
|
||||
notification_ios.dependency 'MicrosoftFluentUI/Obscurable_ios'
|
||||
notification_ios.dependency 'MicrosoftFluentUI/Label_ios'
|
||||
notification_ios.dependency 'MicrosoftFluentUI/Separator_ios'
|
||||
notification_ios.preserve_paths = ["ios/FluentUI/Notification/Notification.resources.xcfilelist"]
|
||||
notification_ios.source_files = ["ios/FluentUI/Notification/**/*.{swift,h}"]
|
||||
notification_ios.preserve_paths = ["ios/FluentUI/Vnext/Notification/Notification.resources.xcfilelist"]
|
||||
notification_ios.source_files = ["ios/FluentUI/Vnext/Notification/**/*.{swift,h}"]
|
||||
end
|
||||
|
||||
s.subspec 'Obscurable_ios' do |obscurable_ios|
|
||||
|
|
|
@ -44,15 +44,6 @@ class NotificationViewDemoController: DemoController {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
var delayForHiding: TimeInterval {
|
||||
switch self {
|
||||
case .primaryToast, .primaryToastWithImageAndTitle, .primaryBar, .primaryOutlineBar, .neutralBar:
|
||||
return 2
|
||||
default:
|
||||
return .infinity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
@ -72,59 +63,97 @@ class NotificationViewDemoController: DemoController {
|
|||
container.addArrangedSubview(UIView())
|
||||
}
|
||||
addTitle(text: variant.displayText)
|
||||
container.addArrangedSubview(createNotificationView(forVariant: variant))
|
||||
container.addArrangedSubview(createButton(title: "Show", action: #selector(showNotificationView)))
|
||||
|
||||
let showButton = MSFButton(style: .secondary, size: .small, action: { [weak self] _ in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.createNotificationView(forVariant: variant).showNotification(in: strongSelf.view) {
|
||||
$0.hide(after: 3.0)
|
||||
}
|
||||
})
|
||||
showButton.state.text = "Show"
|
||||
container.addArrangedSubview(showButton)
|
||||
|
||||
container.alignment = .leading
|
||||
}
|
||||
}
|
||||
|
||||
private func createNotificationView(forVariant variant: Variant) -> NotificationView {
|
||||
private func createNotificationView(forVariant variant: Variant) -> MSFNotification {
|
||||
switch variant {
|
||||
case .primaryToast:
|
||||
return NotificationView(style: .primaryToast).setup(style: .primaryToast,
|
||||
message: "Mail Archived",
|
||||
actionTitle: "Undo",
|
||||
action: { [weak self] in self?.showMessage("`Undo` tapped") })
|
||||
let notification = MSFNotification(style: .primaryToast, message: "Mail Archived")
|
||||
notification.state.actionButtonTitle = "Undo"
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Undo` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .primaryToastWithImageAndTitle:
|
||||
return NotificationView(style: .primaryToast).setup(style: .primaryToast,
|
||||
title: "Kat's iPhoneX",
|
||||
message: "Listen to Emails • 7 mins",
|
||||
image: UIImage(named: "play-in-circle-24x24"),
|
||||
action: { [weak self] in self?.showMessage("`Dismiss` tapped") },
|
||||
messageAction: { [weak self] in self?.showMessage("`Listen to emails` tapped") })
|
||||
let notification = MSFNotification(style: .primaryToast, message: "Listen to Emails • 7 mins")
|
||||
notification.state.title = "Kat's iPhoneX"
|
||||
notification.state.image = UIImage(named: "play-in-circle-24x24")
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Dismiss` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
notification.state.messageButtonAction = { [weak self] in
|
||||
self?.showMessage("`Listen to emails` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .neutralToast:
|
||||
return NotificationView(style: .neutralToast).setup(style: .neutralToast,
|
||||
message: "Some items require you to sign in to view them",
|
||||
actionTitle: "Sign in",
|
||||
action: { [weak self] in self?.showMessage("`Sign in` tapped") })
|
||||
let notification = MSFNotification(style: .neutralToast, message: "Some items require you to sign in to view them")
|
||||
notification.state.actionButtonTitle = "Sign in"
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Sign in` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .dangerToast:
|
||||
return NotificationView(style: .dangerToast).setup(style: .dangerToast,
|
||||
message: "There was a problem, and your recent changes may not have saved",
|
||||
actionTitle: "Retry",
|
||||
action: { [weak self] in self?.showMessage("`Retry` tapped") })
|
||||
let notification = MSFNotification(style: .dangerToast, message: "There was a problem, and your recent changes may not have saved")
|
||||
notification.state.actionButtonTitle = "Retry"
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Retry` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .warningToast:
|
||||
return NotificationView(style: .warningToast).setup(style: .warningToast,
|
||||
message: "Read Only",
|
||||
action: { [weak self] in self?.showMessage("`Dismiss` tapped") })
|
||||
let notification = MSFNotification(style: .warningToast, message: "Read Only")
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Dismiss` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .primaryBar:
|
||||
return NotificationView(style: .primaryBar).setup(style: .primaryBar,
|
||||
message: "Updating...")
|
||||
let notification = MSFNotification(style: .primaryBar, message: "Updating...")
|
||||
return notification
|
||||
case .primaryOutlineBar:
|
||||
return NotificationView(style: .primaryOutlineBar).setup(style: .primaryOutlineBar,
|
||||
message: "Mail Sent")
|
||||
let notification = MSFNotification(style: .primaryOutlineBar, message: "Mail Sent")
|
||||
return notification
|
||||
case .neutralBar:
|
||||
return NotificationView(style: .neutralBar).setup(style: .neutralBar,
|
||||
message: "No internet connection")
|
||||
let notification = MSFNotification(style: .neutralBar, message: "No internet connection")
|
||||
return notification
|
||||
case .persistentBarWithAction:
|
||||
return NotificationView(style: .neutralBar).setup(style: .neutralBar,
|
||||
message: "This error can be taken action on with the action on the right.",
|
||||
actionTitle: "Action",
|
||||
action: { [weak self] in self?.showMessage("`Action` tapped") })
|
||||
let notification = MSFNotification(style: .neutralBar, message: "This error can be taken action on with the action on the right.")
|
||||
notification.state.actionButtonTitle = "Action"
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Action` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
case .persistentBarWithCancel:
|
||||
return NotificationView(style: .neutralBar).setup(style: .neutralBar,
|
||||
message: "This error can be tapped or dismissed with the icon to the right.",
|
||||
action: { [weak self] in self?.showMessage("`Dismiss` tapped") },
|
||||
messageAction: { [weak self] in self?.showMessage("`Dismiss` tapped") })
|
||||
let notification = MSFNotification(style: .neutralBar, message: "This error can be tapped or dismissed with the icon to the right.")
|
||||
notification.state.actionButtonAction = { [weak self] in
|
||||
self?.showMessage("`Dismiss` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
notification.state.messageButtonAction = { [weak self] in
|
||||
self?.showMessage("`Dismiss` tapped")
|
||||
notification.hide()
|
||||
}
|
||||
return notification
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -36,16 +36,13 @@ struct NotificationDemoView: View {
|
|||
let image = showImage ? UIImage(named: "play-in-circle-24x24") : nil
|
||||
let actionButtonAction = hasActionButtonAction ? { showAlert = true } : nil
|
||||
let messageButtonAction = hasMessageAction ? { showAlert = true } : nil
|
||||
let dismissAction = { showAlert = true }
|
||||
|
||||
let notification = NotificationViewSwiftUI(style: style,
|
||||
message: message,
|
||||
title: title,
|
||||
image: image,
|
||||
actionButtonTitle: actionButtonTitle,
|
||||
actionButtonAction: actionButtonAction,
|
||||
messageButtonAction: messageButtonAction,
|
||||
dismissAction: dismissAction)
|
||||
let notification = FluentNotification(style: style,
|
||||
message: message,
|
||||
title: title,
|
||||
image: image,
|
||||
actionButtonTitle: actionButtonTitle,
|
||||
actionButtonAction: actionButtonAction,
|
||||
messageButtonAction: messageButtonAction)
|
||||
|
||||
VStack {
|
||||
notification
|
||||
|
@ -138,7 +135,6 @@ struct NotificationDemoView: View {
|
|||
image: image,
|
||||
actionButtonTitle: actionButtonTitle,
|
||||
actionButtonAction: actionButtonAction,
|
||||
messageButtonAction: messageButtonAction,
|
||||
dismissAction: dismissAction)
|
||||
messageButtonAction: messageButtonAction)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
5314E0E625F012C00099271A /* UIViewController+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DA7B4232C33A80013E41B /* UIViewController+Navigation.swift */; };
|
||||
5314E0E725F012C00099271A /* UINavigationItem+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C8BD22DD47120086F899 /* UINavigationItem+Navigation.swift */; };
|
||||
5314E0EC25F012C40099271A /* NavigationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C88022DD13230086F899 /* NavigationAnimator.swift */; };
|
||||
43488C46270FAD1300124C71 /* NotificationView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43488C44270FAD0200124C71 /* NotificationView_SwiftUI.swift */; };
|
||||
43488C46270FAD1300124C71 /* FluentNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43488C44270FAD0200124C71 /* FluentNotification.swift */; };
|
||||
4B53505F27F63E3F0033B47F /* NotificationModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B53505E27F63E3F0033B47F /* NotificationModifiers.swift */; };
|
||||
436F6B4226F4926B00D18073 /* NotificationTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436F6B4026F4926B00D18073 /* MSFNotificationTokens.swift */; };
|
||||
4BF01DA027B3A862005B32F2 /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF01D9F27B3A861005B32F2 /* UIApplication+Extensions.swift */; };
|
||||
|
@ -67,7 +67,6 @@
|
|||
5314E0F225F012C80099271A /* ShyHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87122DD13230086F899 /* ShyHeaderView.swift */; };
|
||||
5314E0F325F012C80099271A /* ShyHeaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87022DD13230086F899 /* ShyHeaderController.swift */; };
|
||||
5314E0F825F012CB0099271A /* LargeTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87A22DD13230086F899 /* LargeTitleView.swift */; };
|
||||
5314E10125F012E60099271A /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B6617223A41E2900E801DD /* NotificationView.swift */; };
|
||||
5314E10A25F014600099271A /* Obscurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53BCB0CD253A4E8C00620960 /* Obscurable.swift */; };
|
||||
5314E11625F015EA0099271A /* PersonaBadgeViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BA27872319DC0D0001563C /* PersonaBadgeViewDataSource.swift */; };
|
||||
5314E11725F015EA0099271A /* PersonaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46D3F922151D95F0029772C /* PersonaCell.swift */; };
|
||||
|
@ -212,7 +211,7 @@
|
|||
1168630222E131CF0088B302 /* TabBarItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabBarItemView.swift; sourceTree = "<group>"; };
|
||||
1168630322E131CF0088B302 /* TabBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = "<group>"; };
|
||||
118D9847230BBA2300BC0B72 /* TabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarItem.swift; sourceTree = "<group>"; };
|
||||
43488C44270FAD0200124C71 /* NotificationView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView_SwiftUI.swift; sourceTree = "<group>"; };
|
||||
43488C44270FAD0200124C71 /* FluentNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FluentNotification.swift; sourceTree = "<group>"; };
|
||||
497DC2D724185885008D86F8 /* PillButtonBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillButtonBar.swift; sourceTree = "<group>"; };
|
||||
497DC2D824185885008D86F8 /* PillButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillButton.swift; sourceTree = "<group>"; };
|
||||
4B53505E27F63E3F0033B47F /* NotificationModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationModifiers.swift; sourceTree = "<group>"; };
|
||||
|
@ -299,7 +298,6 @@
|
|||
A5961FA2218A25D100E2A506 /* PopupMenuItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuItemCell.swift; sourceTree = "<group>"; };
|
||||
A5961FA4218A260500E2A506 /* PopupMenuSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuSectionHeaderView.swift; sourceTree = "<group>"; };
|
||||
A5961FA6218A2E4500E2A506 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; };
|
||||
A5B6617223A41E2900E801DD /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
|
||||
A5B87AF0211BD4380038C37C /* UIFont+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5B87AF3211E16360038C37C /* DrawerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DrawerController.swift; sourceTree = "<group>"; };
|
||||
A5B87AF4211E16360038C37C /* DrawerTransitionAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DrawerTransitionAnimator.swift; sourceTree = "<group>"; };
|
||||
|
@ -515,9 +513,10 @@
|
|||
4BA9B8B5279F2940007536F5 /* Notification */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
43488C44270FAD0200124C71 /* FluentNotification.swift */,
|
||||
4BBD651E2755FD9500A8B09E /* MSFNotificationView.swift */,
|
||||
4B53505E27F63E3F0033B47F /* NotificationModifiers.swift */,
|
||||
43488C44270FAD0200124C71 /* NotificationView_SwiftUI.swift */,
|
||||
4BF01D9927B37CF8005B32F2 /* NotificationTokens.swift */,
|
||||
);
|
||||
path = Notification;
|
||||
sourceTree = "<group>";
|
||||
|
@ -759,15 +758,6 @@
|
|||
path = "Popup Menu";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5B6617123A41DEC00E801DD /* Notification */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5B6617223A41E2900E801DD /* NotificationView.swift */,
|
||||
4BF01D9927B37CF8005B32F2 /* NotificationTokens.swift */,
|
||||
);
|
||||
path = Notification;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5B87AED211BCA4A0038C37C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -847,7 +837,6 @@
|
|||
5314DFF425F0069C0099271A /* IndeterminateProgressBar */,
|
||||
5314DFF025F0042E0099271A /* Label */,
|
||||
FD41C86D22DD12A20086F899 /* Navigation */,
|
||||
A5B6617123A41DEC00E801DD /* Notification */,
|
||||
5314DFF925F008F10099271A /* Obscurable */,
|
||||
C77A04EB25F0469C001B3EB6 /* Other Cells */,
|
||||
B426613C214731AC00E25423 /* People Picker */,
|
||||
|
@ -1456,7 +1445,6 @@
|
|||
5314E25D25F0238E0099271A /* UIFont+Extension.swift in Sources */,
|
||||
C77A04B825F03DD1001B3EB6 /* String+Date.swift in Sources */,
|
||||
4B53505F27F63E3F0033B47F /* NotificationModifiers.swift in Sources */,
|
||||
5314E10125F012E60099271A /* NotificationView.swift in Sources */,
|
||||
5314E14425F016860099271A /* PageCardPresenterController.swift in Sources */,
|
||||
5314E11B25F015EA0099271A /* PersonaListView.swift in Sources */,
|
||||
925D462026FD18B200179583 /* AliasTokens.swift in Sources */,
|
||||
|
@ -1496,7 +1484,7 @@
|
|||
5314E1A125F01A7C0099271A /* TableViewCellFileAccessoryView.swift in Sources */,
|
||||
5314E1D625F01E4A0099271A /* SearchBar.swift in Sources */,
|
||||
5314E0A825F010070099271A /* DrawerPresentationController.swift in Sources */,
|
||||
43488C46270FAD1300124C71 /* NotificationView_SwiftUI.swift in Sources */,
|
||||
43488C46270FAD1300124C71 /* FluentNotification.swift in Sources */,
|
||||
5314E06425F00EFD0099271A /* CalendarViewMonthBannerView.swift in Sources */,
|
||||
5314E18E25F0195C0099271A /* ShimmerLinesView.swift in Sources */,
|
||||
80AECC21263339E3005AF2F3 /* BottomSheetController.swift in Sources */,
|
||||
|
|
|
@ -1,511 +0,0 @@
|
|||
//
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the MIT License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: - NotificationView
|
||||
|
||||
/**
|
||||
`NotificationView` can be used to present a toast (`.primaryToast` and `.neutralToast` styles) or a notification bar (`.primaryBar`, `.primaryOutlineBar`, and `.neutralBar` styles) with information and actions at the bottom of the screen.
|
||||
|
||||
This view can be inserted into layout manually, if needed, or by using the `show` and `hide` methods which implement default presentation (with or without animation). Positioning is done using constraints.
|
||||
|
||||
By default only one visible toast is allowed in the app. When a new toast is shown, the previous one is hidden. This behavior can be changed via `allowsMultipleToasts` static property.
|
||||
|
||||
When `action` exists but no `actionTitle` is provided, a "cross" (X) image will be used for action button.
|
||||
|
||||
When used as a notification bar some functionality like `title`, `image` and actions are not supported. A convenience method `setupAsBar` can be used to initialize notification bar and assign only supported properties.
|
||||
*/
|
||||
@objc(MSFNotificationView)
|
||||
open class NotificationView: UIView, TokenizedControlInternal, ControlConfiguration {
|
||||
@objc public static var allowsMultipleToasts: Bool = false
|
||||
|
||||
private static var currentToast: NotificationView? {
|
||||
didSet {
|
||||
if allowsMultipleToasts {
|
||||
currentToast = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc open private(set) var isShown: Bool = false
|
||||
|
||||
private var isHiding: Bool = false
|
||||
private var completionsForHide: [() -> Void] = []
|
||||
|
||||
private var action: (() -> Void)?
|
||||
private var messageAction: (() -> Void)?
|
||||
|
||||
private lazy var container: UIStackView = {
|
||||
let container = UIStackView()
|
||||
container.distribution = .fill
|
||||
container.alignment = .center
|
||||
container.axis = .horizontal
|
||||
container.isAccessibilityElement = true
|
||||
container.spacing = tokens.horizontalSpacing
|
||||
container.layoutMargins = UIEdgeInsets(top: tokens.verticalPadding, left: 0, bottom: tokens.verticalPadding, right: 0)
|
||||
return container
|
||||
}()
|
||||
private let textContainer: UIStackView = {
|
||||
let textContainer = UIStackView()
|
||||
textContainer.axis = .vertical
|
||||
textContainer.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
textContainer.setContentHuggingPriority(.required, for: .vertical)
|
||||
return textContainer
|
||||
}()
|
||||
private let imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return imageView
|
||||
}()
|
||||
private let titleLabel: Label = {
|
||||
let titleLabel = Label(style: .button1)
|
||||
titleLabel.numberOfLines = 0
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
titleLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
titleLabel.setContentHuggingPriority(.required, for: .vertical)
|
||||
return titleLabel
|
||||
}()
|
||||
private let messageLabel: Label = {
|
||||
let messageLabel = Label(style: .subhead)
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
messageLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
messageLabel.setContentHuggingPriority(.required, for: .vertical)
|
||||
return messageLabel
|
||||
}()
|
||||
private let actionButton: UIButton = {
|
||||
let actionButton = UIButton(type: .system)
|
||||
actionButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
actionButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
actionButton.titleLabel?.font = TextStyle.button1.font
|
||||
actionButton.titleLabel?.adjustsFontForContentSizeCategory = true
|
||||
return actionButton
|
||||
}()
|
||||
private let separator = Separator(style: .shadow, orientation: .horizontal)
|
||||
|
||||
private var hasSingleLineLayout: Bool {
|
||||
return titleLabel.text?.isEmpty == true && messageLabel.frame.height == messageLabel.font.deviceLineHeight
|
||||
}
|
||||
private var constraintWhenHidden: NSLayoutConstraint!
|
||||
private var constraintWhenShown: NSLayoutConstraint!
|
||||
private var backgroundLayer = CALayer()
|
||||
private var perimeterShadow = CALayer()
|
||||
private var ambientShadow = CALayer()
|
||||
|
||||
@objc public init(style: MSFNotificationStyle) {
|
||||
self.style = style
|
||||
|
||||
let tokens = NotificationTokens()
|
||||
tokens.style = style
|
||||
self.tokens = tokens
|
||||
|
||||
super.init(frame: .zero)
|
||||
initialize()
|
||||
}
|
||||
|
||||
required public init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
open override func removeFromSuperview() {
|
||||
super.removeFromSuperview()
|
||||
|
||||
isShown = false
|
||||
if NotificationView.currentToast == self {
|
||||
NotificationView.currentToast = nil
|
||||
}
|
||||
}
|
||||
|
||||
open override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
perimeterShadow.frame = bounds
|
||||
perimeterShadow.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: tokens.cornerRadius).cgPath
|
||||
|
||||
ambientShadow.frame = bounds
|
||||
ambientShadow.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: tokens.cornerRadius).cgPath
|
||||
|
||||
backgroundLayer.frame = bounds
|
||||
}
|
||||
|
||||
@objc open func initialize() {
|
||||
addSubview(separator)
|
||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(container)
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
container.addArrangedSubview(imageView)
|
||||
container.addArrangedSubview(textContainer)
|
||||
textContainer.addArrangedSubview(titleLabel)
|
||||
textContainer.addArrangedSubview(messageLabel)
|
||||
container.addArrangedSubview(actionButton)
|
||||
|
||||
let horizontalPadding: CGFloat! = tokens.horizontalPadding
|
||||
NSLayoutConstraint.activate([
|
||||
separator.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
separator.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
separator.bottomAnchor.constraint(equalTo: topAnchor),
|
||||
container.topAnchor.constraint(equalTo: topAnchor),
|
||||
container.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
container.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: horizontalPadding),
|
||||
container.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -horizontalPadding)
|
||||
])
|
||||
|
||||
updateForStyle()
|
||||
|
||||
accessibilityElements = [container, actionButton]
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleMessageTap)))
|
||||
|
||||
actionButton.addTarget(self, action: #selector(handleActionButtonTap), for: .touchUpInside)
|
||||
|
||||
layer.insertSublayer(backgroundLayer, at: 0)
|
||||
layer.insertSublayer(perimeterShadow, below: backgroundLayer)
|
||||
layer.insertSublayer(ambientShadow, below: perimeterShadow)
|
||||
}
|
||||
|
||||
/// `setup` is used to initialize the view before showing.
|
||||
/// - Parameters:
|
||||
/// - style: The style that defines presentation and functionality of the view.
|
||||
/// - title: The title text that is shown on the top of the view (only supported in toasts).
|
||||
/// - message: The message text that is shown below title (if present) or vertically centered in the view.
|
||||
/// - image: The image that is shown at the leading edge of the view (only supported in toasts).
|
||||
/// - actionTitle: The title for action on the trailing edge of the view.
|
||||
/// - action: The closure to be called when action button is tapped by a user.
|
||||
/// - messageAction: The closure to be called when the body of the view (except action button) is tapped by a user (only supported in toasts).
|
||||
/// - Returns: Reference to this view that can be used for "chained" calling of `show`. Can be ignored.
|
||||
@discardableResult
|
||||
@objc open func setup(style: MSFNotificationStyle,
|
||||
title: String = "",
|
||||
message: String,
|
||||
image: UIImage? = nil,
|
||||
actionTitle: String = "",
|
||||
action: (() -> Void)? = nil,
|
||||
messageAction: (() -> Void)? = nil) -> Self {
|
||||
let title = style.supportsTitle ? title : ""
|
||||
let isTitleEmpty = title.isEmpty
|
||||
let image = style.supportsImage ? image : nil
|
||||
|
||||
titleLabel.text = title
|
||||
titleLabel.isHidden = isTitleEmpty
|
||||
messageLabel.text = message
|
||||
messageLabel.style = !isTitleEmpty ? .subhead : tokens.style.isToast ? .button1 : .subhead
|
||||
|
||||
imageView.image = image?.renderingMode == .automatic ? image?.withRenderingMode(.alwaysTemplate) : image
|
||||
imageView.isHidden = image == nil
|
||||
|
||||
if action != nil || style.shouldAlwaysShowActionButton {
|
||||
if actionTitle.isEmpty {
|
||||
let actionImage = UIImage.staticImageNamed("dismiss-20x20")
|
||||
actionImage?.accessibilityLabel = "Accessibility.Dismiss.Label".localized
|
||||
actionButton.setImage(actionImage, for: .normal)
|
||||
actionButton.setTitle(nil, for: .normal)
|
||||
} else {
|
||||
actionButton.setImage(nil, for: .normal)
|
||||
actionButton.setTitle(actionTitle, for: .normal)
|
||||
}
|
||||
actionButton.isHidden = false
|
||||
messageLabel.textAlignment = .natural
|
||||
} else {
|
||||
actionButton.isHidden = true
|
||||
messageLabel.textAlignment = .center
|
||||
}
|
||||
|
||||
self.action = action
|
||||
self.messageAction = messageAction
|
||||
|
||||
updateAccessibility(title: title, message: message, hasMessageAction: messageAction != nil)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
/// `show` is used to present the view inside a container view: insert into layout and show with optional animation. Constraints are used for the view positioning.
|
||||
/// - Parameters:
|
||||
/// - view: The container view where this view will be presented.
|
||||
/// - anchorView: The view used as the bottom anchor for presentation (notification view is always presented up from the anchor). When no anchor view is provided the bottom anchor of the container's safe area is used.
|
||||
/// - animated: Indicates whether to use animation during presentation or not.
|
||||
/// - completion: The closure to be called after presentation is completed. Can be used to call `hide` with a delay.
|
||||
@objc open func show(in view: UIView, from anchorView: UIView? = nil, animated: Bool = true, completion: ((NotificationView) -> Void)? = nil) {
|
||||
if isShown {
|
||||
return
|
||||
}
|
||||
|
||||
let style = tokens.style
|
||||
let presentationOffset: CGFloat! = tokens.presentationOffset
|
||||
if style.isToast, let currentToast = NotificationView.currentToast {
|
||||
currentToast.hide(animated: animated) {
|
||||
self.show(in: view, from: anchorView, animated: animated, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
if let anchorView = anchorView, anchorView.superview == view {
|
||||
view.insertSubview(self, belowSubview: anchorView)
|
||||
} else {
|
||||
view.addSubview(self)
|
||||
}
|
||||
|
||||
let anchor = anchorView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor
|
||||
constraintWhenHidden = topAnchor.constraint(equalTo: anchor)
|
||||
constraintWhenShown = bottomAnchor.constraint(equalTo: anchor, constant: -presentationOffset)
|
||||
|
||||
var constraints = [NSLayoutConstraint]()
|
||||
constraints.append(animated ? constraintWhenHidden : constraintWhenShown)
|
||||
if style.needsFullWidth {
|
||||
constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor))
|
||||
constraints.append(trailingAnchor.constraint(equalTo: view.trailingAnchor))
|
||||
} else {
|
||||
let offset = ((view.safeAreaLayoutGuide.layoutFrame.width - self.intrinsicContentSize.width) / 2) + presentationOffset
|
||||
constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: offset))
|
||||
constraints.append(trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -offset))
|
||||
}
|
||||
NSLayoutConstraint.activate(constraints)
|
||||
|
||||
isShown = true
|
||||
if style.isToast {
|
||||
NotificationView.currentToast = self
|
||||
}
|
||||
|
||||
let completionForShow = { (_: Bool) in
|
||||
UIAccessibility.post(notification: .layoutChanged, argument: self)
|
||||
completion?(self)
|
||||
}
|
||||
if animated {
|
||||
view.layoutIfNeeded()
|
||||
UIView.animate(withDuration: style.animationDurationForShow, delay: 0, usingSpringWithDamping: style.animationDampingRatio, initialSpringVelocity: 0, animations: {
|
||||
self.constraintWhenHidden.isActive = false
|
||||
self.constraintWhenShown.isActive = true
|
||||
view.layoutIfNeeded()
|
||||
}, completion: completionForShow)
|
||||
} else {
|
||||
completionForShow(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `show` is used to present the view inside a container controller: insert into controller's view layout and show with optional animation. When container is a `UINavigationController` then its toolbar (if visible) is used as the bottom anchor for presentation. When container is `UITabBarController`, its tab bar is used as the anchor. Constraints are used for the view positioning.
|
||||
/// - Parameters:
|
||||
/// - controller: The container controller whose view will be used for this view's presentation.
|
||||
/// - animated: Indicates whether to use animation during presentation or not.
|
||||
/// - completion: The closure to be called after presentation is completed. Can be used to call `hide` with a delay.
|
||||
@objc open func show(from controller: UIViewController, animated: Bool = true, completion: ((NotificationView) -> Void)? = nil) {
|
||||
if isShown {
|
||||
return
|
||||
}
|
||||
|
||||
var anchorView: UIView?
|
||||
if let controller = controller as? UINavigationController, !controller.isToolbarHidden {
|
||||
anchorView = controller.toolbar
|
||||
}
|
||||
if let controller = controller as? UITabBarController {
|
||||
anchorView = controller.tabBar
|
||||
}
|
||||
|
||||
show(in: controller.view, from: anchorView, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
/// `hide` is used to dismiss the presented view: hide with optional animation and remove from the container.
|
||||
/// - Parameters:
|
||||
/// - delay: The delay used for the start of dismissal. Default is 0.
|
||||
/// - animated: Indicates whether to use animation during dismissal or not.
|
||||
/// - completion: The closure to be called after dismissal is completed.
|
||||
@objc open func hide(after delay: TimeInterval = 0, animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
if !isShown || delay == .infinity {
|
||||
return
|
||||
}
|
||||
|
||||
if delay > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.hide(animated: animated, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let completion = completion {
|
||||
completionsForHide.append(completion)
|
||||
}
|
||||
let completionForHide = {
|
||||
self.removeFromSuperview()
|
||||
UIAccessibility.post(notification: .layoutChanged, argument: nil)
|
||||
|
||||
self.completionsForHide.forEach { $0() }
|
||||
self.completionsForHide.removeAll()
|
||||
}
|
||||
if animated {
|
||||
if !isHiding {
|
||||
isHiding = true
|
||||
UIView.animate(withDuration: tokens.style.animationDurationForHide, animations: {
|
||||
self.constraintWhenShown.isActive = false
|
||||
self.constraintWhenHidden.isActive = true
|
||||
self.superview?.layoutIfNeeded()
|
||||
}, completion: { _ in
|
||||
self.isHiding = false
|
||||
completionForHide()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
completionForHide()
|
||||
}
|
||||
}
|
||||
|
||||
open override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
updateNotificationTokens()
|
||||
updateWindowSpecificColors()
|
||||
}
|
||||
|
||||
// MARK: - TokenizedControl
|
||||
public typealias TokenType = NotificationTokens
|
||||
public func overrideTokens(_ tokens: NotificationTokens?) -> Self {
|
||||
overrideTokens = tokens
|
||||
return self
|
||||
}
|
||||
|
||||
var state: NotificationView { self }
|
||||
|
||||
/// Design token set for this control, to use in place of the control's default Fluent tokens.
|
||||
var overrideTokens: NotificationTokens?
|
||||
|
||||
/// Style to draw the control.
|
||||
public var style: MSFNotificationStyle {
|
||||
didSet {
|
||||
tokens.style = style
|
||||
}
|
||||
}
|
||||
|
||||
var tokens: NotificationTokens {
|
||||
didSet {
|
||||
if tokens.style != oldValue.style {
|
||||
tokens.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
var suggestedWidth: CGFloat = size.width
|
||||
var availableLabelWidth = suggestedWidth
|
||||
|
||||
if tokens.style.needsFullWidth {
|
||||
if let windowWidth = window?.safeAreaLayoutGuide.layoutFrame.width {
|
||||
availableLabelWidth = windowWidth
|
||||
}
|
||||
} else {
|
||||
if let windowWidth = window?.frame.width {
|
||||
suggestedWidth = windowWidth
|
||||
}
|
||||
|
||||
// for iPad regular width size, notification toast might look too wide
|
||||
if traitCollection.userInterfaceIdiom == .pad &&
|
||||
traitCollection.horizontalSizeClass == .regular &&
|
||||
traitCollection.preferredContentSizeCategory < .accessibilityMedium {
|
||||
suggestedWidth = max(suggestedWidth / 2, 375.0)
|
||||
} else {
|
||||
suggestedWidth -= (safeAreaInsets.left + safeAreaInsets.right + 2 * tokens.presentationOffset)
|
||||
}
|
||||
suggestedWidth = ceil(suggestedWidth)
|
||||
availableLabelWidth = suggestedWidth
|
||||
}
|
||||
|
||||
availableLabelWidth -= (2 * tokens.horizontalPadding)
|
||||
if !actionButton.isHidden {
|
||||
let actionButtonSize = actionButton.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
availableLabelWidth -= (actionButtonSize.width + tokens.horizontalSpacing)
|
||||
}
|
||||
if !imageView.isHidden {
|
||||
let imageSize = imageView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
availableLabelWidth -= (imageSize.width + tokens.horizontalSpacing)
|
||||
}
|
||||
|
||||
var suggestedHeight: CGFloat
|
||||
let messagelabelSize = messageLabel.systemLayoutSizeFitting(CGSize(width: availableLabelWidth, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultHigh)
|
||||
suggestedHeight = messagelabelSize.height
|
||||
|
||||
// there are different veritcal padding depending on the text we show
|
||||
// `tokens.verticalPaddingForOneLine` is used when only messagelabel is shown and all the text fits in oneline
|
||||
// Otherwise, use `tokens.veritcalPadding` for top and bottom
|
||||
var hasSingleLineLayout = false
|
||||
if titleLabel.text?.isEmpty == true {
|
||||
hasSingleLineLayout = (messagelabelSize.height == messageLabel.font.deviceLineHeight)
|
||||
} else {
|
||||
let titleLabelSize = titleLabel.systemLayoutSizeFitting(CGSize(width: availableLabelWidth, height: 0), withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultHigh)
|
||||
suggestedHeight += titleLabelSize.height
|
||||
}
|
||||
|
||||
let suggestedVerticalPadding: CGFloat! = hasSingleLineLayout ? tokens.verticalPaddingForOneLine : tokens.verticalPadding
|
||||
suggestedHeight += 2 * suggestedVerticalPadding
|
||||
suggestedHeight = ceil(max(suggestedHeight, hasSingleLineLayout ? tokens.minimumHeightForOneLine : tokens.minimumHeight))
|
||||
container.layoutMargins = UIEdgeInsets(top: suggestedVerticalPadding, left: 0, bottom: suggestedVerticalPadding, right: 0)
|
||||
|
||||
return CGSize(width: suggestedWidth, height: suggestedHeight)
|
||||
}
|
||||
|
||||
open override var intrinsicContentSize: CGSize {
|
||||
return sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))
|
||||
}
|
||||
|
||||
open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
self.updateWindowSpecificColors()
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
||||
private func updateForStyle() {
|
||||
clipsToBounds = !tokens.style.needsSeparator
|
||||
layer.masksToBounds = false
|
||||
|
||||
backgroundLayer.cornerRadius = tokens.cornerRadius
|
||||
backgroundLayer.cornerCurve = .continuous
|
||||
|
||||
perimeterShadow.shadowColor = UIColor(dynamicColor: tokens.perimeterShadowColor).cgColor
|
||||
perimeterShadow.shadowRadius = tokens.perimeterShadowBlur
|
||||
perimeterShadow.shadowOffset = CGSize(width: tokens.perimeterShadowOffsetX, height: tokens.perimeterShadowOffsetY)
|
||||
perimeterShadow.shadowOpacity = 1.0
|
||||
|
||||
ambientShadow.shadowColor = UIColor(dynamicColor: tokens.ambientShadowColor).cgColor
|
||||
ambientShadow.shadowRadius = tokens.ambientShadowBlur
|
||||
ambientShadow.shadowOffset = CGSize(width: tokens.ambientShadowOffsetX, height: tokens.ambientShadowOffsetY)
|
||||
ambientShadow.shadowOpacity = 1.0
|
||||
|
||||
separator.isHidden = !tokens.style.needsSeparator
|
||||
|
||||
updateWindowSpecificColors()
|
||||
}
|
||||
|
||||
private func updateNotificationTokens() {
|
||||
let tokens = TokenResolver.tokens(for: self, fluentTheme: fluentTheme)
|
||||
self.tokens = tokens
|
||||
}
|
||||
|
||||
private func updateWindowSpecificColors() {
|
||||
backgroundLayer.backgroundColor = UIColor(dynamicColor: tokens.backgroundColor).cgColor
|
||||
let foregroundColor = UIColor(dynamicColor: tokens.foregroundColor)
|
||||
imageView.tintColor = foregroundColor
|
||||
titleLabel.textColor = foregroundColor
|
||||
messageLabel.textColor = foregroundColor
|
||||
actionButton.tintColor = foregroundColor
|
||||
}
|
||||
|
||||
private func updateAccessibility(title: String, message: String, hasMessageAction: Bool) {
|
||||
container.accessibilityLabel = "\(title), \(message)"
|
||||
container.accessibilityTraits = hasMessageAction ? .button : .staticText
|
||||
}
|
||||
|
||||
@objc private func handleActionButtonTap() {
|
||||
hide(animated: true)
|
||||
action?()
|
||||
}
|
||||
|
||||
@objc private func handleMessageTap() {
|
||||
guard let messageAction = messageAction else {
|
||||
return
|
||||
}
|
||||
hide(animated: true)
|
||||
messageAction()
|
||||
}
|
||||
}
|
|
@ -32,50 +32,40 @@ import SwiftUI
|
|||
/// Action to be dispatched by tapping on the toast/bar notification.
|
||||
var messageButtonAction: (() -> Void)? { get set }
|
||||
|
||||
/// Action to be dispatched when dismissing toast/bar notification.
|
||||
var dismissAction: (() -> Void)? { get set }
|
||||
|
||||
/// Design token set for this control, to use in place of the control's default Fluent tokens.
|
||||
var overrideTokens: NotificationTokens? { get set }
|
||||
}
|
||||
|
||||
/// View that represents the Notification.
|
||||
public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
@Environment(\.fluentTheme) var fluentTheme: FluentTheme
|
||||
@ObservedObject var state: MSFNotificationStateImpl
|
||||
@Binding var isPresented: Bool
|
||||
@State private var bottomOffsetForDismissedState: CGFloat = 0
|
||||
@State private var bottomOffset: CGFloat = 0
|
||||
let defaultTokens: NotificationTokens = .init()
|
||||
var tokens: NotificationTokens {
|
||||
let tokens = resolvedTokens
|
||||
tokens.style = state.style
|
||||
return tokens
|
||||
}
|
||||
|
||||
public func overrideTokens(_ tokens: NotificationTokens?) -> NotificationViewSwiftUI {
|
||||
state.overrideTokens = tokens
|
||||
return self
|
||||
}
|
||||
|
||||
public struct FluentNotification: View, ConfigurableTokenizedControl {
|
||||
/// Creates the FluentNotification
|
||||
/// - Parameters:
|
||||
/// - style: `MSFNotificationStyle` enum value that defines the style of the Notification being presented.
|
||||
/// - shouldSelfPresent: Whether the notification should present itself (SwiftUI environment) or externally (UIKit environment)
|
||||
/// - message: Text for the main title area of the control. If there is a title, the message becomes subtext.
|
||||
/// - isPresented: Controls whether the Notification is being presented.
|
||||
/// - title: Optional text to draw above the message area.
|
||||
/// - image: Optional icon to draw at the leading edge of the control.
|
||||
/// - actionButtonTitle:Title to display in the action button on the trailing edge of the control.
|
||||
/// - actionButtonAction: Action to be dispatched by the action button on the trailing edge of the control.
|
||||
/// - messageButtonAction: Action to be dispatched by tapping on the toast/bar notification.
|
||||
public init(style: MSFNotificationStyle,
|
||||
shouldSelfPresent: Bool = true,
|
||||
message: String,
|
||||
isPresented: Binding<Bool>? = nil,
|
||||
title: String = "",
|
||||
image: UIImage? = nil,
|
||||
actionButtonTitle: String = "",
|
||||
actionButtonAction: (() -> Void)? = nil,
|
||||
messageButtonAction: (() -> Void)? = nil,
|
||||
dismissAction: (() -> Void)? = nil) {
|
||||
messageButtonAction: (() -> Void)? = nil) {
|
||||
let state = MSFNotificationStateImpl(style: style, message: message)
|
||||
state.title = title
|
||||
state.image = image
|
||||
state.actionButtonTitle = actionButtonTitle
|
||||
state.actionButtonAction = actionButtonAction
|
||||
state.messageButtonAction = messageButtonAction
|
||||
state.dismissAction = dismissAction
|
||||
self.state = state
|
||||
self.shouldSelfPresent = shouldSelfPresent
|
||||
|
||||
if let isPresented = isPresented {
|
||||
_isPresented = isPresented
|
||||
|
@ -85,64 +75,76 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometryReader in
|
||||
let width: CGFloat = {
|
||||
let geometryReaderWidth = geometryReader.size.width
|
||||
let isFullLength = state.style.isToast && horizontalSizeClass == .regular
|
||||
return isFullLength ? geometryReaderWidth / 2 : geometryReaderWidth - (2 * tokens.presentationOffset)
|
||||
}()
|
||||
let cornerRadius = tokens.cornerRadius
|
||||
let ambientShadowOffsetY = tokens.ambientShadowOffsetY
|
||||
let geometryReaderLocalFrame = geometryReader.frame(in: .local)
|
||||
if !shouldSelfPresent {
|
||||
notification
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
let proposedSize = proxy.size
|
||||
let proposedWidth = proposedSize.width
|
||||
let calculatedNotificationWidth: CGFloat = {
|
||||
let isHalfLength = state.style.isToast && horizontalSizeClass == .regular
|
||||
return isHalfLength ? proposedWidth / 2 : proposedWidth - (2 * tokens.presentationOffset)
|
||||
}()
|
||||
|
||||
innerContents
|
||||
.onTapGesture {
|
||||
if let messageAction = state.messageButtonAction, let dismissAction = state.dismissAction {
|
||||
isPresented = false
|
||||
dismissAction()
|
||||
messageAction()
|
||||
notification
|
||||
.frame(width: calculatedNotificationWidth, alignment: .center)
|
||||
.onChange(of: isPresented, perform: { present in
|
||||
if present {
|
||||
presentAnimated()
|
||||
} else {
|
||||
dismissAnimated()
|
||||
}
|
||||
})
|
||||
.padding(.bottom, tokens.bottomPresentationPadding)
|
||||
.onSizeChange { newSize in
|
||||
bottomOffsetForDismissedState = newSize.height + (tokens.ambientShadowOffsetY / 2)
|
||||
// Bottom offset is only updated when the notification isn't presented to account for the new notification height (if presented, offset doesn't need to be updated since it grows upward vertically)
|
||||
if !isPresented {
|
||||
bottomOffset = bottomOffsetForDismissedState
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: width, alignment: .bottom)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(Color(dynamicColor: tokens.outlineColor), lineWidth: tokens.outlineWidth)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color(dynamicColor: tokens.backgroundColor))
|
||||
)
|
||||
.shadow(color: Color(dynamicColor: tokens.ambientShadowColor),
|
||||
radius: tokens.ambientShadowBlur,
|
||||
x: tokens.ambientShadowOffsetX,
|
||||
y: ambientShadowOffsetY)
|
||||
.shadow(color: Color(dynamicColor: tokens.perimeterShadowColor),
|
||||
radius: tokens.perimeterShadowBlur,
|
||||
x: tokens.perimeterShadowOffsetX,
|
||||
y: tokens.perimeterShadowOffsetY)
|
||||
)
|
||||
.onChange(of: isPresented, perform: { present in
|
||||
if present {
|
||||
presentAnimated()
|
||||
} else {
|
||||
dismissAnimated()
|
||||
}
|
||||
})
|
||||
.padding(.bottom, tokens.bottomPresentationPadding)
|
||||
.onSizeChange { newSize in
|
||||
bottomOffsetForDismissedState = newSize.height + (ambientShadowOffsetY / 2)
|
||||
// Bottom offset is only updated when the notification isn't presented to account for the new notification height (if presented, offset doesn't need to be updated since it grows upward vertically)
|
||||
if !isPresented {
|
||||
bottomOffset = bottomOffsetForDismissedState
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity,
|
||||
alignment: .bottom)
|
||||
.offset(y: bottomOffset)
|
||||
.position(x: geometryReaderLocalFrame.midX,
|
||||
y: geometryReaderLocalFrame.midY)
|
||||
.offset(y: bottomOffset)
|
||||
.frame(width: proposedWidth, height: proposedSize.height, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.fluentTheme) var fluentTheme: FluentTheme
|
||||
@ObservedObject var state: MSFNotificationStateImpl
|
||||
let defaultTokens: NotificationTokens = .init()
|
||||
var tokens: NotificationTokens {
|
||||
let tokens = resolvedTokens
|
||||
tokens.style = state.style
|
||||
return tokens
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var notification: some View {
|
||||
innerContents
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: tokens.cornerRadius)
|
||||
.strokeBorder(Color(dynamicColor: tokens.outlineColor), lineWidth: tokens.outlineWidth)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: tokens.cornerRadius)
|
||||
.fill(Color(dynamicColor: tokens.backgroundColor))
|
||||
)
|
||||
.shadow(color: Color(dynamicColor: tokens.ambientShadowColor),
|
||||
radius: tokens.ambientShadowBlur,
|
||||
x: tokens.ambientShadowOffsetX,
|
||||
y: tokens.ambientShadowOffsetY)
|
||||
.shadow(color: Color(dynamicColor: tokens.perimeterShadowColor),
|
||||
radius: tokens.perimeterShadowBlur,
|
||||
x: tokens.perimeterShadowOffsetX,
|
||||
y: tokens.perimeterShadowOffsetY)
|
||||
)
|
||||
.onTapGesture {
|
||||
if let messageAction = state.messageButtonAction {
|
||||
isPresented = false
|
||||
messageAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var image: some View {
|
||||
if state.style.isToast {
|
||||
|
@ -193,7 +195,7 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
|
||||
@ViewBuilder
|
||||
private var button: some View {
|
||||
if let buttonAction = state.actionButtonAction, let actionTitle = state.actionButtonTitle, let dismissAction = state.dismissAction {
|
||||
if let buttonAction = state.actionButtonAction, let actionTitle = state.actionButtonTitle {
|
||||
let foregroundColor = tokens.foregroundColor
|
||||
let horizontalPadding = tokens.horizontalPadding
|
||||
let verticalPadding = tokens.verticalPadding
|
||||
|
@ -201,7 +203,6 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
if actionTitle.isEmpty {
|
||||
SwiftUI.Button(action: {
|
||||
isPresented = false
|
||||
dismissAction()
|
||||
buttonAction()
|
||||
}, label: {
|
||||
Image("dismiss-20x20", bundle: FluentUIFramework.resourceBundle)
|
||||
|
@ -213,7 +214,6 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
} else {
|
||||
SwiftUI.Button(actionTitle) {
|
||||
isPresented = false
|
||||
dismissAction()
|
||||
buttonAction()
|
||||
}
|
||||
.lineLimit(1)
|
||||
|
@ -228,7 +228,12 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
@ViewBuilder
|
||||
private var innerContents: some View {
|
||||
if hasCenteredText {
|
||||
textContainer
|
||||
HStack {
|
||||
Spacer()
|
||||
textContainer
|
||||
Spacer()
|
||||
}
|
||||
.frame(minHeight: tokens.minimumHeight)
|
||||
} else {
|
||||
HStack(spacing: tokens.horizontalSpacing) {
|
||||
image
|
||||
|
@ -266,6 +271,19 @@ public struct NotificationViewSwiftUI: View, ConfigurableTokenizedControl {
|
|||
bottomOffset = bottomOffsetForDismissedState
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
@Binding private var isPresented: Bool
|
||||
@State private var bottomOffsetForDismissedState: CGFloat = 0
|
||||
@State private var bottomOffset: CGFloat = 0
|
||||
|
||||
// When true, the notification view will take up all proposed space
|
||||
// and automatically position itself within it.
|
||||
// isPresented only works when shouldSelfPresent is true.
|
||||
//
|
||||
// When false, the view will have a fitting, flexible width and self-sized height.
|
||||
// In this mode the notification should be positioned and presented externally.
|
||||
private let shouldSelfPresent: Bool
|
||||
}
|
||||
|
||||
class MSFNotificationStateImpl: NSObject, ControlConfiguration, MSFNotificationState {
|
||||
|
@ -286,9 +304,6 @@ class MSFNotificationStateImpl: NSObject, ControlConfiguration, MSFNotificationS
|
|||
/// Action to be dispatched by tapping on the toast/bar notification.
|
||||
@Published public var messageButtonAction: (() -> Void)?
|
||||
|
||||
/// Action to be dispatched when dismissing toast/bar notification.
|
||||
@Published public var dismissAction: (() -> Void)?
|
||||
|
||||
/// Design token set for this control, to use in place of the control's default Fluent tokens.
|
||||
@Published var overrideTokens: NotificationTokens?
|
||||
|
||||
|
@ -308,8 +323,7 @@ class MSFNotificationStateImpl: NSObject, ControlConfiguration, MSFNotificationS
|
|||
image: UIImage? = nil,
|
||||
actionButtonTitle: String? = nil,
|
||||
actionButtonAction: (() -> Void)? = nil,
|
||||
messageButtonAction: (() -> Void)? = nil,
|
||||
dismissAction: (() -> Void)? = nil) {
|
||||
messageButtonAction: (() -> Void)? = nil) {
|
||||
self.init(style: style, message: message)
|
||||
|
||||
self.title = title
|
||||
|
@ -317,6 +331,5 @@ class MSFNotificationStateImpl: NSObject, ControlConfiguration, MSFNotificationS
|
|||
self.actionButtonTitle = actionButtonTitle
|
||||
self.actionButtonAction = actionButtonAction
|
||||
self.messageButtonAction = messageButtonAction
|
||||
self.dismissAction = dismissAction
|
||||
}
|
||||
}
|
|
@ -15,7 +15,9 @@ import UIKit
|
|||
/// - message: The primary text to display in the Notification.
|
||||
@objc public init(style: MSFNotificationStyle,
|
||||
message: String) {
|
||||
notification = NotificationViewSwiftUI(style: style, message: message)
|
||||
notification = FluentNotification(style: style,
|
||||
shouldSelfPresent: false,
|
||||
message: message)
|
||||
super.init(AnyView(notification))
|
||||
}
|
||||
|
||||
|
@ -24,7 +26,6 @@ import UIKit
|
|||
}
|
||||
|
||||
// MARK: - Show/Hide Methods
|
||||
|
||||
public func showNotification(in view: UIView, completion: ((MSFNotification) -> Void)? = nil) {
|
||||
guard self.view.window == nil else {
|
||||
return
|
||||
|
@ -52,12 +53,14 @@ import UIKit
|
|||
|
||||
var constraints = [NSLayoutConstraint]()
|
||||
constraints.append(animated ? constraintWhenHidden : constraintWhenShown)
|
||||
if style.needsFullWidth {
|
||||
constraints.append(self.view.leadingAnchor.constraint(equalTo: view.leadingAnchor))
|
||||
constraints.append(self.view.trailingAnchor.constraint(equalTo: view.trailingAnchor))
|
||||
constraints.append(self.centerXAnchor.constraint(equalTo: view.centerXAnchor))
|
||||
|
||||
let isHalfLength = state.style.isToast && traitCollection.horizontalSizeClass == .regular
|
||||
if isHalfLength {
|
||||
constraints.append(self.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5))
|
||||
} else {
|
||||
constraints.append(self.view.centerXAnchor.constraint(equalTo: view.centerXAnchor))
|
||||
constraints.append(self.view.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -2 * presentationOffset))
|
||||
let padding = notification.tokens.presentationOffset
|
||||
constraints.append(self.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -2 * padding))
|
||||
}
|
||||
NSLayoutConstraint.activate(constraints)
|
||||
|
||||
|
@ -71,7 +74,7 @@ import UIKit
|
|||
}
|
||||
|
||||
if animated {
|
||||
view.layoutIfNeeded()
|
||||
view.layoutIfNeeded()
|
||||
UIView.animate(withDuration: style.animationDurationForShow,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: style.animationDampingRatio,
|
||||
|
@ -87,21 +90,12 @@ import UIKit
|
|||
}
|
||||
|
||||
@objc public func hide(after delay: TimeInterval = 0, completion: (() -> Void)? = nil) {
|
||||
let hideDelay: TimeInterval = {
|
||||
switch state.style {
|
||||
case .primaryToast, .primaryBar, .primaryOutlineBar, .neutralBar:
|
||||
return delay
|
||||
case .neutralToast, .dangerToast, .warningToast:
|
||||
return (delay == 0) ? delay : .infinity
|
||||
}
|
||||
}()
|
||||
|
||||
guard self.view.window != nil && constraintWhenHidden != nil && hideDelay != .infinity else {
|
||||
guard self.window != nil && constraintWhenHidden != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard hideDelay == 0 else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + hideDelay) { [weak self] in
|
||||
guard delay == 0 else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.hide(completion: completion)
|
||||
}
|
||||
return
|
||||
|
@ -134,7 +128,6 @@ import UIKit
|
|||
}
|
||||
|
||||
// MARK: - Private variables
|
||||
|
||||
private static var allowsMultipleToasts: Bool = false
|
||||
private static var currentToast: MSFNotification? {
|
||||
didSet {
|
||||
|
@ -149,6 +142,6 @@ import UIKit
|
|||
private var completionsForHide: [() -> Void] = []
|
||||
private var constraintWhenHidden: NSLayoutConstraint!
|
||||
private var constraintWhenShown: NSLayoutConstraint!
|
||||
private var notification: NotificationViewSwiftUI!
|
||||
private var notification: FluentNotification!
|
||||
private var isHiding: Bool = false
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
|
||||
/// Presents a Notification on top of the modified View.
|
||||
/// - Parameters:
|
||||
/// - style: `MSFNotificationStyle` enum value that defines the style of the Notification being presented.
|
||||
|
@ -18,7 +17,6 @@ public extension View {
|
|||
/// - actionButtonTitle:Title to display in the action button on the trailing edge of the control.
|
||||
/// - actionButtonAction: Action to be dispatched by the action button on the trailing edge of the control.
|
||||
/// - messageButtonAction: Action to be dispatched by tapping on the toast/bar notification.
|
||||
/// - dismissAction: Action to be dispatched when dismissing toast/bar notification.
|
||||
/// - Returns: The modified view with the capability of presenting a Notification.
|
||||
func presentNotification(style: MSFNotificationStyle,
|
||||
message: String,
|
||||
|
@ -32,15 +30,22 @@ public extension View {
|
|||
dismissAction: (() -> Void)? = nil) -> some View {
|
||||
self.presentingView(isPresented: isPresented,
|
||||
isBlocking: isBlocking) {
|
||||
NotificationViewSwiftUI(style: style,
|
||||
message: message,
|
||||
isPresented: isPresented,
|
||||
title: title,
|
||||
image: image,
|
||||
actionButtonTitle: actionButtonTitle,
|
||||
actionButtonAction: actionButtonAction,
|
||||
messageButtonAction: messageButtonAction,
|
||||
dismissAction: dismissAction)
|
||||
FluentNotification(style: style,
|
||||
message: message,
|
||||
isPresented: isPresented,
|
||||
title: title,
|
||||
image: image,
|
||||
actionButtonTitle: actionButtonTitle,
|
||||
actionButtonAction: actionButtonAction,
|
||||
messageButtonAction: messageButtonAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension FluentNotification {
|
||||
/// Provides a custom design token set to be used when drawing this control.
|
||||
func overrideTokens(_ tokens: NotificationTokens?) -> FluentNotification {
|
||||
state.overrideTokens = tokens
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче