diff --git a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift index 97d4b6f22d..6b8b867120 100644 --- a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift +++ b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift @@ -56,6 +56,7 @@ struct NotificationDemoView: View { @State var previewPresented: Bool = true @State var notificationID: UUID = UUID() @State var autoReappear: Bool = true + @State var expandButtonMode: Int = 0 @ObservedObject var fluentTheme: FluentTheme = .shared private var triggerModel = FluentNotificationTriggerModel() let customTheme: FluentTheme = { @@ -178,6 +179,7 @@ struct NotificationDemoView: View { message: hasMessage ? message : nil, attributedMessage: hasAttribute && hasMessage ? attributedMessage : nil, messageLineLimit: messageLineLimit, + enableExandableMessageText: expandButtonMode == 1, title: hasTitle ? title : nil, attributedTitle: hasAttribute && hasTitle ? attributedTitle : nil, image: image, @@ -188,6 +190,8 @@ struct NotificationDemoView: View { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: dismissButtonAction, + showExpandButtonInPlaceOfDismissButton: expandButtonMode == 2, + expandButtonAction: (expandButtonMode == 2) ? { showAlert = true } : nil, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, showFromBottom: showFromBottom, @@ -234,6 +238,7 @@ struct NotificationDemoView: View { message: hasMessage ? message : nil, attributedMessage: hasAttribute && hasMessage ? attributedMessage : nil, messageLineLimit: messageLineLimit, + enableExandableMessageText: expandButtonMode == 1, isPresented: $isPresented, title: hasTitle ? title : nil, attributedTitle: hasAttribute && hasTitle ? attributedTitle : nil, @@ -244,6 +249,8 @@ struct NotificationDemoView: View { actionButtonAction: actionButtonAction, showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, + showExpandButtonInPlaceOfDismissButton: expandButtonMode == 2, + expandButtonAction: (expandButtonMode == 2) ? { showAlert = true } : nil, messageButtonAction: messageButtonAction, showFromBottom: showFromBottom, verticalOffset: verticalOffset, @@ -280,6 +287,7 @@ struct NotificationDemoView: View { LabeledContent { TextField("Line Limit", value: $messageLineLimit, formatter: integerFormatter) .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) } label: { Text("Message Line Limit") } @@ -316,6 +324,12 @@ struct NotificationDemoView: View { Toggle("Can Show Action & Dismiss Buttons", isOn: $showActionButtonAndDismissButton) Toggle("Has Message Action", isOn: $hasMessageAction) Toggle("Swipe to Dismiss Enabled", isOn: $swipeToDismissEnabled) + Picker(selection: $expandButtonMode, label: Text("Expand Button Mode")) { + Text("none").tag(0) + Text("expandable message").tag(1) + Text("custom expand action").tag(2) + } + .frame(maxWidth: .infinity, alignment: .leading) } FluentListSection("Style") { diff --git a/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift new file mode 100644 index 0000000000..99c04cf6a0 --- /dev/null +++ b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift @@ -0,0 +1,140 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +/// A text view that automatically detects truncation and supports expand/collapse functionality. +/// +/// This component measures the rendered text height and determines if truncation is occurring +/// based on the specified line limit. It provides an optional callback when expandability changes, +/// allowing parent components to react (e.g., show/hide an expand button). +/// +public struct ExpandableText: View { + // MARK: - Public Initializers + + /// Creates an expandable text view with plain text. + /// - Parameters: + /// - text: The text to display. + /// - lineLimit: Maximum number of lines to show when collapsed. Default is 2. + /// - isExpanded: Optional binding to control expansion state externally. + /// - font: The font to use for measurement and display. If nil, uses system body font. + /// - onExpandabilityChange: Callback invoked when the expandability status changes. + public init( + _ text: String, + lineLimit: Int = 0, + isExpanded: Binding? = nil, + font: UIFont? = nil, + onExpandabilityChange: ((Bool) -> Void)? = nil + ) { + self.text = text + self.attributedText = nil + self.lineLimit = lineLimit + self.externalIsExpanded = isExpanded + self.font = font + self.onExpandabilityChange = onExpandabilityChange + } + + /// Creates an expandable text view with attributed text. + /// - Parameters: + /// - attributedText: The attributed text to display. + /// - lineLimit: Maximum number of lines to show when collapsed. Default is 2. + /// - isExpanded: Optional binding to control expansion state externally. + /// - onExpandabilityChange: Callback invoked when the expandability status changes. + public init( + _ attributedText: NSAttributedString, + lineLimit: Int = 0, + isExpanded: Binding? = nil, + onExpandabilityChange: ((Bool) -> Void)? = nil + ) { + self.text = attributedText.string + self.attributedText = attributedText + self.lineLimit = lineLimit + self.externalIsExpanded = isExpanded + self.font = attributedText.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + self.onExpandabilityChange = onExpandabilityChange + } + + // MARK: - Public Properties + + public var body: some View { + Group { + if let attributed = attributedText { + Text(AttributedString(attributed)) + } else { + Text(text) + .font(font.map { Font($0) } ?? .body) + } + } + .lineLimit(isExpanded ? nil : (lineLimit > 0 ? lineLimit : nil)) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self) { geo in + geo.size.width + } action: { width in + calculateTruncation(availableWidth: width) + } + } + + // MARK: - Private Properties + + private let text: String + private let attributedText: NSAttributedString? + private let lineLimit: Int + private let font: UIFont? + private let onExpandabilityChange: ((Bool) -> Void)? + + @State private var isExpandable: Bool = false + @State private var internalIsExpanded: Bool = false + @State private var availableWidth: CGFloat = 0 + + private var externalIsExpanded: Binding? + + /// The current expansion state, reading from external binding if provided, otherwise using internal state. + private var isExpanded: Bool { + get { externalIsExpanded?.wrappedValue ?? internalIsExpanded } + nonmutating set { + if let binding = externalIsExpanded { + binding.wrappedValue = newValue + } else { + internalIsExpanded = newValue + } + } + } + + /// The font to use for rendering and measurements, with fallback to body style. + private var effectiveFont: UIFont { + font ?? UIFont.preferredFont(forTextStyle: .body) + } + + /// The height of a single line of text using the effective font. + private var singleLineHeight: CGFloat { + ceil(effectiveFont.lineHeight + max(0, effectiveFont.leading)) + } + + // MARK: - Private Methods + + /// Calculates whether the text would be truncated by comparing the full text height to the maximum allowed height. + /// - Parameter availableWidth: The available width for rendering the text. + private func calculateTruncation(availableWidth: CGFloat) { + guard availableWidth > 0 else { return } + + let messageText: String + if let attributedText { + messageText = attributedText.string + } else { + messageText = text + } + // Calculate the full height the text would take without line limit + let fullHeight = messageText.preferredSize( + for: effectiveFont, + width: availableWidth, + numberOfLines: 0 + ).height + let maxHeight = singleLineHeight * CGFloat(lineLimit < 1 ? Int.max : lineLimit) + + let newExpandable = fullHeight > maxHeight + isExpandable = newExpandable + onExpandabilityChange?(newExpandable) + } +} diff --git a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift index 87588c7e24..15d2131dad 100644 --- a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift +++ b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift @@ -23,6 +23,9 @@ import SwiftUI /// Integer value that sets the maximum number of lines will show for a message var messageLineLimit: Int { get set } + /// If the message text should be expandabled to view the entire mesage if it is truncated due to the messageLineLimit + var enableExandableMessageText: Bool { get set } + /// Optional text to draw above the message area. var title: String? { get set } @@ -58,6 +61,12 @@ import SwiftUI /// Action to be dispatched by the dismiss button on the trailing edge of the control. var defaultDismissButtonAction: (() -> Void)? { get set } + /// If dismiss button should be replaced an expand button + var showExpandButtonInPlaceOfDismissButton: Bool { get set } + + /// Action to be taken when `showExpandButtonInPlaceOfDismissButton` is enabled + var expandButtonAction: (() -> Void)? { get set } + /// Action to be dispatched by tapping on the toast/bar notification. var messageButtonAction: (() -> Void)? { get set } @@ -99,6 +108,8 @@ public struct FluentNotification: View, TokenizedControlView { /// - isFlexibleWidthToast: Whether the width of the toast is set based on the width of the screen or on its contents. /// - message: Optional text for the main title area of the control. If there is a title, the message becomes subtext. /// - attributedMessage: Optional attributed text for the main title area of the control. If there is a title, the message becomes subtext. If set, it will override the message parameter. + /// - messageLineLimit: The maximum number of lines the message can show. Any exess text is truncated. + /// - enableExandableMessageText: If enabled, a expand button will be shown in place of the dimiss icon when the text is truncted. Tapping the expand button will display all lines of text. /// - isPresented: Controls whether the Notification is being presented. /// - title: Optional text to draw above the message area. /// - attributedTitle: Optional attributed text to draw above the message area. If set, it will override the title parameter. @@ -109,6 +120,7 @@ public struct FluentNotification: View, TokenizedControlView { /// - actionButtonAction: Action to be dispatched by the action button on the trailing edge of the control. /// - showDefaultDismissActionButton: Bool to control if the Notification has a dismiss action by default. /// - defaultDismissButtonAction: Action to be dispatched by the dismiss button of the trailing edge of the control. + /// - expandButtonAction: Action to be taken when showExpandButtonInPlaceOfDismissButton is enabled /// - messageButtonAction: Action to be dispatched by tapping on the toast/bar notification. /// - showFromBottom: Defines whether the notification shows from the bottom of the presenting view or the top. /// - verticalOffset: How much to vertically offset the notification from its default position. @@ -118,6 +130,7 @@ public struct FluentNotification: View, TokenizedControlView { message: String? = nil, attributedMessage: NSAttributedString? = nil, messageLineLimit: Int = 0, + enableExandableMessageText: Bool = false, isPresented: Binding? = nil, title: String? = nil, attributedTitle: NSAttributedString? = nil, @@ -129,6 +142,8 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, + showExpandButtonInPlaceOfDismissButton: Bool = false, + expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, showFromBottom: Bool = true, @@ -139,6 +154,7 @@ public struct FluentNotification: View, TokenizedControlView { message: message, attributedMessage: attributedMessage, messageLineLimit: messageLineLimit, + enableExandableMessageText: enableExandableMessageText, title: title, attributedTitle: attributedTitle, image: image, @@ -149,6 +165,8 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: defaultDismissButtonAction, + showExpandButtonInPlaceOfDismissButton: showExpandButtonInPlaceOfDismissButton, + expandButtonAction: expandButtonAction, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, showFromBottom: showFromBottom, @@ -158,7 +176,6 @@ public struct FluentNotification: View, TokenizedControlView { self.shouldSelfPresent = shouldSelfPresent self.isFlexibleWidthToast = isFlexibleWidthToast && style.isToast self.triggerModel = triggerModel - self.tokenSet = NotificationTokenSet(style: { state.style }) if let isPresented = isPresented { @@ -204,21 +221,46 @@ public struct FluentNotification: View, TokenizedControlView { @ViewBuilder var messageLabel: some View { if let attributedMessage = state.attributedMessage { - Text(AttributedString(attributedMessage)) - .fixedSize(horizontal: false, vertical: true) + if state.enableExandableMessageText { + ExpandableText( + attributedMessage, + lineLimit: state.messageLineLimit, + isExpanded: $isMessageLabelExpanded, + onExpandabilityChange: { isExpandable in + isMessageLabelExpandable = isExpandable + } + ) + } else { + Text(AttributedString(attributedMessage)) + .lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil) + .fixedSize(horizontal: false, vertical: true) + } } else if let message = state.message { - Text(message) - .font(.init(tokenSet[.regularTextFont].uiFont)) + if state.enableExandableMessageText { + ExpandableText( + message, + lineLimit: state.messageLineLimit, + isExpanded: $isMessageLabelExpanded, + font: tokenSet[.regularTextFont].uiFont, + onExpandabilityChange: { isExpandable in + isMessageLabelExpandable = isExpandable + } + ) + } else { + Text(message) + .lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil) + .font(.init(tokenSet[.regularTextFont].uiFont)) + } } } @ViewBuilder var textContainer: some View { VStack(alignment: .leading) { - if hasSecondTextRow { - titleLabel - } - messageLabel.lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil) + if hasSecondTextRow { + titleLabel + } + messageLabel } .padding(.vertical, NotificationTokenSet.verticalPadding) } @@ -266,6 +308,26 @@ public struct FluentNotification: View, TokenizedControlView { } } + @ViewBuilder + var expandButton: some View { + HStack { + SwiftUI.Button(action: { + if state.showExpandButtonInPlaceOfDismissButton, let customAction = state.expandButtonAction { + customAction() + } else if state.enableExandableMessageText { + // Default behavior: toggle expansion state + isMessageLabelExpanded.toggle() + } else { + preconditionFailure("FluentNotification expandButton should be getting rendered given the current state") + } + }, label: { + Image("chevron-up-20x20", bundle: FluentUIFramework.resourceBundle) + .accessibilityLabel("Accessibility.Expand.Label".localized) + }) + .hoverEffect() + } + } + let messageButtonAction = state.messageButtonAction @ViewBuilder var innerContents: some View { @@ -296,14 +358,23 @@ public struct FluentNotification: View, TokenizedControlView { .buttonStyle(.borderless) #endif // os(visionOS) .layoutPriority(1) - if dismissButtonAction != nil { - Spacer() - dismissButton + if (state.showExpandButtonInPlaceOfDismissButton && state.expandButtonAction != nil) || (state.enableExandableMessageText && isMessageLabelExpandable && !isMessageLabelExpanded) { + Spacer() + expandButton #if os(visionOS) - .buttonStyle(.borderless) + .buttonStyle(.borderless) #endif // os(visionOS) - .layoutPriority(1) - } + .layoutPriority(1) + } else { + if dismissButtonAction != nil { + Spacer() + dismissButton + #if os(visionOS) + .buttonStyle(.borderless) + #endif // os(visionOS) + .layoutPriority(1) + } + } } .onSizeChange { newSize in innerContentsSize = newSize @@ -504,6 +575,8 @@ public struct FluentNotification: View, TokenizedControlView { @State private var attributedTitleSize: CGSize = CGSize() @State private var opacity: CGFloat = 0 @State private var bumpVerticalOffset: CGFloat = 0 + @State private var isMessageLabelExpandable = false + @State private var isMessageLabelExpanded = false // When true, the notification view will take up all proposed space // and automatically position itself within it. @@ -530,11 +603,14 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { @Published var showDefaultDismissActionButton: Bool @Published var showActionButtonAndDismissButton: Bool @Published var defaultDismissButtonAction: (() -> Void)? + @Published var expandButtonAction: (() -> Void)? @Published var showFromBottom: Bool @Published var backgroundGradient: LinearGradientInfo? @Published var verticalOffset: CGFloat @Published var onDismiss: (() -> Void)? @Published var swipeToDismissEnabled: Bool + @Published var showExpandButtonInPlaceOfDismissButton: Bool + @Published var enableExandableMessageText: Bool /// Title to display in the action button on the trailing edge of the control. /// @@ -566,6 +642,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { actionButtonAction: nil, showDefaultDismissActionButton: nil, showActionButtonAndDismissButton: false, + defaultDismissButtonAction: nil, + expandButtonAction: nil, messageButtonAction: nil, swipeToDismissEnabled: false, showFromBottom: true, @@ -576,6 +654,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { message: String? = nil, attributedMessage: NSAttributedString? = nil, messageLineLimit: Int = 0, + enableExandableMessageText: Bool = false, title: String? = nil, attributedTitle: NSAttributedString? = nil, image: UIImage? = nil, @@ -586,6 +665,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, + showExpandButtonInPlaceOfDismissButton: Bool = false, + expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, showFromBottom: Bool = true, @@ -607,8 +688,10 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { self.showActionButtonAndDismissButton = showActionButtonAndDismissButton self.swipeToDismissEnabled = swipeToDismissEnabled self.defaultDismissButtonAction = defaultDismissButtonAction + self.showExpandButtonInPlaceOfDismissButton = showExpandButtonInPlaceOfDismissButton + self.expandButtonAction = expandButtonAction self.verticalOffset = verticalOffset - + self.enableExandableMessageText = enableExandableMessageText super.init() } } diff --git a/Sources/FluentUI_iOS/Components/Notification/Notification.resources.xcfilelist b/Sources/FluentUI_iOS/Components/Notification/Notification.resources.xcfilelist index 96466ae9c3..35744612c6 100644 --- a/Sources/FluentUI_iOS/Components/Notification/Notification.resources.xcfilelist +++ b/Sources/FluentUI_iOS/Components/Notification/Notification.resources.xcfilelist @@ -1 +1,2 @@ +chevron-up-20x20.imageset dismiss-20x20.imageset diff --git a/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/Contents.json b/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/Contents.json new file mode 100644 index 0000000000..273a774325 --- /dev/null +++ b/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "chevron-up-20x20.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/chevron-up-20x20.svg b/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/chevron-up-20x20.svg new file mode 100644 index 0000000000..85d806faea --- /dev/null +++ b/Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/chevron-up-20x20.svg @@ -0,0 +1,3 @@ + + +