From c3a79261fa0cb0e51154b03a3f06ff5fe9b1f415 Mon Sep 17 00:00:00 2001 From: Des Marks Date: Mon, 30 Mar 2026 10:03:41 -0700 Subject: [PATCH 1/5] Implement an 'Expand' dismiss button override --- ...tificationViewDemoController_SwiftUI.swift | 3 + .../Notification/FluentNotification.swift | 60 ++++++++++++++++--- .../Notification.resources.xcfilelist | 1 + .../chevron-up-20x20.imageset/Contents.json | 16 +++++ .../chevron-up-20x20.svg | 3 + 5 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/Contents.json create mode 100644 Sources/FluentUI_iOS/Resources/FluentUI-ios.xcassets/chevron-up-20x20.imageset/chevron-up-20x20.svg diff --git a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift index 97d4b6f22d..342dca8ac1 100644 --- a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift +++ b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift @@ -188,6 +188,8 @@ struct NotificationDemoView: View { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: dismissButtonAction, + overrideDismissButonWithExpandedActionButton: true, + expandButtonAction: nil, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, showFromBottom: showFromBottom, @@ -280,6 +282,7 @@ struct NotificationDemoView: View { LabeledContent { TextField("Line Limit", value: $messageLineLimit, formatter: integerFormatter) .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) } label: { Text("Message Line Limit") } diff --git a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift index 87588c7e24..f230b9ff47 100644 --- a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift +++ b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift @@ -58,6 +58,9 @@ import SwiftUI /// Action to be dispatched by the dismiss button on the trailing edge of the control. var defaultDismissButtonAction: (() -> Void)? { get set } + /// Action to be dispatched by the expand button on the trailing edge of the control. + var expandButtonAction: (() -> Void)? { get set } + /// Action to be dispatched by tapping on the toast/bar notification. var messageButtonAction: (() -> Void)? { get set } @@ -74,6 +77,9 @@ import SwiftUI /// /// If this property is nil, then this notification will use the background color defined by its design tokens. var backgroundGradient: LinearGradientInfo? { get set } + + // If dismiss button should be replaced an expand button + var overrideDismissButonWithExpandedActionButton: Bool { get set } } /// Exposes public published properties that are observed by the `FluentNotification`. Enables @@ -109,6 +115,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: Optional custom action to be dispatched by the expand button. If not provided, the default behavior expands the message to show all lines. /// - 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. @@ -129,6 +136,8 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, + overrideDismissButonWithExpandedActionButton: Bool = false, + expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, showFromBottom: Bool = true, @@ -149,6 +158,8 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: defaultDismissButtonAction, + overrideDismissButonWithExpandedActionButton: overrideDismissButonWithExpandedActionButton, + expandButtonAction: expandButtonAction, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, showFromBottom: showFromBottom, @@ -158,7 +169,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 { @@ -266,6 +276,25 @@ public struct FluentNotification: View, TokenizedControlView { } } + @ViewBuilder + var expandButton: some View { + HStack { + SwiftUI.Button(action: { + if let customAction = state.expandButtonAction { + customAction() + } else { + // Default behavior: expand message to show all lines + state.messageLineLimit = 0 + } + state.overrideDismissButonWithExpandedActionButton.toggle() + }, 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 +325,23 @@ public struct FluentNotification: View, TokenizedControlView { .buttonStyle(.borderless) #endif // os(visionOS) .layoutPriority(1) - if dismissButtonAction != nil { - Spacer() - dismissButton + if state.overrideDismissButonWithExpandedActionButton { + 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 @@ -530,11 +568,13 @@ 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 overrideDismissButonWithExpandedActionButton: Bool /// Title to display in the action button on the trailing edge of the control. /// @@ -566,6 +606,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { actionButtonAction: nil, showDefaultDismissActionButton: nil, showActionButtonAndDismissButton: false, + defaultDismissButtonAction: nil, + expandButtonAction: nil, messageButtonAction: nil, swipeToDismissEnabled: false, showFromBottom: true, @@ -586,6 +628,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, + overrideDismissButonWithExpandedActionButton: Bool = false, + expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, showFromBottom: Bool = true, @@ -607,6 +651,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { self.showActionButtonAndDismissButton = showActionButtonAndDismissButton self.swipeToDismissEnabled = swipeToDismissEnabled self.defaultDismissButtonAction = defaultDismissButtonAction + self.overrideDismissButonWithExpandedActionButton = overrideDismissButonWithExpandedActionButton + self.expandButtonAction = expandButtonAction self.verticalOffset = verticalOffset 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 @@ + + + From 3197aa6f1979138bc4160f80174ded5d9bfabe13 Mon Sep 17 00:00:00 2001 From: Des Marks Date: Tue, 31 Mar 2026 14:43:23 -0700 Subject: [PATCH 2/5] Updates --- ...tificationViewDemoController_SwiftUI.swift | 1 + .../ExpandableText/ExpandableText.swift | 171 ++++++++++++++++++ .../Notification/FluentNotification.swift | 39 ++-- 3 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift diff --git a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift index 342dca8ac1..ff78386fc9 100644 --- a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift +++ b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift @@ -246,6 +246,7 @@ struct NotificationDemoView: View { actionButtonAction: actionButtonAction, showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, + overrideDismissButonWithExpandedActionButton: true, messageButtonAction: messageButtonAction, showFromBottom: showFromBottom, verticalOffset: verticalOffset, diff --git a/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift new file mode 100644 index 0000000000..1fa45ec153 --- /dev/null +++ b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift @@ -0,0 +1,171 @@ +// +// 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). +/// +/// Example usage: +/// ```swift +/// @State private var isExpanded = false +/// +/// ExpandableText( +/// "Long message that may be truncated...", +/// lineLimit: 3, +/// isExpanded: $isExpanded, +/// onExpandabilityChange: { isExpandable in +/// showExpandButton = isExpandable +/// } +/// ) +/// ``` +public struct ExpandableText: View { + // MARK: - Properties + + private let text: String + private let attributedText: NSAttributedString? + private let lineLimit: Int + private let font: UIFont? + private let onExpandabilityChange: ((Bool) -> Void)? + + // MARK: - State + + @State private var internalIsExpanded: Bool = false + @State private var isExpandable: Bool = false + @State private var availableWidth: CGFloat = 0 + + private var externalIsExpanded: Binding? + + // MARK: - Computed Properties + + /// 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: - 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: - Body + + public var body: some View { + contentText + } + + // MARK: - Private Views + + @ViewBuilder + private var contentText: 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 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 } + + // Calculate the full height the text would take without line limit + let fullHeight: CGFloat + if let attributed = attributedText { + // For attributed text, use the string content with the effective font as approximation + fullHeight = attributed.string.preferredSize( + for: effectiveFont, + width: availableWidth, + numberOfLines: 0 + ).height + } else { + fullHeight = text.preferredSize( + for: effectiveFont, + width: availableWidth, + numberOfLines: 0 + ).height + } + + let maxHeight = singleLineHeight * CGFloat(lineLimit < 1 ? Int.max : lineLimit) + + let newExpandable = fullHeight > maxHeight +// if newExpandable != isExpandable { + isExpandable = newExpandable + onExpandabilityChange?(newExpandable) +// } + } +} diff --git a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift index f230b9ff47..b73e58dc05 100644 --- a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift +++ b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift @@ -214,21 +214,34 @@ public struct FluentNotification: View, TokenizedControlView { @ViewBuilder var messageLabel: some View { if let attributedMessage = state.attributedMessage { - Text(AttributedString(attributedMessage)) - .fixedSize(horizontal: false, vertical: true) + ExpandableText( + attributedMessage, + lineLimit: state.messageLineLimit, + isExpanded: $isMessageLabelExpanded, + onExpandabilityChange: { isExpandable in + isMessageLabelExpandable = isExpandable + } + ) } else if let message = state.message { - Text(message) - .font(.init(tokenSet[.regularTextFont].uiFont)) + ExpandableText( + message, + lineLimit: state.messageLineLimit, + isExpanded: $isMessageLabelExpanded, + font: tokenSet[.regularTextFont].uiFont, + onExpandabilityChange: { isExpandable in + isMessageLabelExpandable = isExpandable + } + ) } } @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) } @@ -283,10 +296,9 @@ public struct FluentNotification: View, TokenizedControlView { if let customAction = state.expandButtonAction { customAction() } else { - // Default behavior: expand message to show all lines - state.messageLineLimit = 0 + // Default behavior: toggle expansion state + isMessageLabelExpanded.toggle() } - state.overrideDismissButonWithExpandedActionButton.toggle() }, label: { Image("chevron-up-20x20", bundle: FluentUIFramework.resourceBundle) .accessibilityLabel("Accessibility.Expand.Label".localized) @@ -325,7 +337,7 @@ public struct FluentNotification: View, TokenizedControlView { .buttonStyle(.borderless) #endif // os(visionOS) .layoutPriority(1) - if state.overrideDismissButonWithExpandedActionButton { + if state.overrideDismissButonWithExpandedActionButton && isMessageLabelExpandable && !isMessageLabelExpanded { Spacer() expandButton #if os(visionOS) @@ -542,6 +554,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. @@ -558,6 +572,7 @@ public struct FluentNotification: View, TokenizedControlView { class MSFNotificationStateImpl: ControlState, MSFNotificationState { @Published var message: String? + @Published var isMultiLine: Bool = false @Published var attributedMessage: NSAttributedString? @Published var messageLineLimit: Int @Published var title: String? From d933a5366fc314c70a291ef9d1538839c078794e Mon Sep 17 00:00:00 2001 From: Des Marks Date: Tue, 31 Mar 2026 15:30:49 -0700 Subject: [PATCH 3/5] Updates --- ...tificationViewDemoController_SwiftUI.swift | 16 ++++++-- .../Notification/FluentNotification.swift | 38 ++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift b/Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift index ff78386fc9..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,8 +190,8 @@ struct NotificationDemoView: View { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: dismissButtonAction, - overrideDismissButonWithExpandedActionButton: true, - expandButtonAction: nil, + showExpandButtonInPlaceOfDismissButton: expandButtonMode == 2, + expandButtonAction: (expandButtonMode == 2) ? { showAlert = true } : nil, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, showFromBottom: showFromBottom, @@ -236,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, @@ -246,7 +249,8 @@ struct NotificationDemoView: View { actionButtonAction: actionButtonAction, showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, - overrideDismissButonWithExpandedActionButton: true, + showExpandButtonInPlaceOfDismissButton: expandButtonMode == 2, + expandButtonAction: (expandButtonMode == 2) ? { showAlert = true } : nil, messageButtonAction: messageButtonAction, showFromBottom: showFromBottom, verticalOffset: verticalOffset, @@ -320,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/Notification/FluentNotification.swift b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift index b73e58dc05..0d8baa2070 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,7 +61,10 @@ import SwiftUI /// Action to be dispatched by the dismiss button on the trailing edge of the control. var defaultDismissButtonAction: (() -> Void)? { get set } - /// Action to be dispatched by the expand button on the trailing edge of the control. + /// If dismiss button should be replaced an expand button + var showExpandButtonInPlaceOfDismissButton: Bool { get set } + + /// Optional override for the action to be called by the expand button on the trailing edge of the control. If left nil, default action will occur. var expandButtonAction: (() -> Void)? { get set } /// Action to be dispatched by tapping on the toast/bar notification. @@ -77,9 +83,6 @@ import SwiftUI /// /// If this property is nil, then this notification will use the background color defined by its design tokens. var backgroundGradient: LinearGradientInfo? { get set } - - // If dismiss button should be replaced an expand button - var overrideDismissButonWithExpandedActionButton: Bool { get set } } /// Exposes public published properties that are observed by the `FluentNotification`. Enables @@ -105,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. @@ -125,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, @@ -136,7 +142,7 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, - overrideDismissButonWithExpandedActionButton: Bool = false, + showExpandButtonInPlaceOfDismissButton: Bool = false, expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, @@ -148,6 +154,7 @@ public struct FluentNotification: View, TokenizedControlView { message: message, attributedMessage: attributedMessage, messageLineLimit: messageLineLimit, + enableExandableMessageText: enableExandableMessageText, title: title, attributedTitle: attributedTitle, image: image, @@ -158,7 +165,7 @@ public struct FluentNotification: View, TokenizedControlView { showDefaultDismissActionButton: showDefaultDismissActionButton, showActionButtonAndDismissButton: showActionButtonAndDismissButton, defaultDismissButtonAction: defaultDismissButtonAction, - overrideDismissButonWithExpandedActionButton: overrideDismissButonWithExpandedActionButton, + showExpandButtonInPlaceOfDismissButton: showExpandButtonInPlaceOfDismissButton, expandButtonAction: expandButtonAction, messageButtonAction: messageButtonAction, swipeToDismissEnabled: swipeToDismissEnabled, @@ -293,11 +300,13 @@ public struct FluentNotification: View, TokenizedControlView { var expandButton: some View { HStack { SwiftUI.Button(action: { - if let customAction = state.expandButtonAction { + if state.showExpandButtonInPlaceOfDismissButton, let customAction = state.expandButtonAction { customAction() - } else { + } 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) @@ -337,7 +346,7 @@ public struct FluentNotification: View, TokenizedControlView { .buttonStyle(.borderless) #endif // os(visionOS) .layoutPriority(1) - if state.overrideDismissButonWithExpandedActionButton && isMessageLabelExpandable && !isMessageLabelExpanded { + if (state.showExpandButtonInPlaceOfDismissButton && state.expandButtonAction != nil) || (state.enableExandableMessageText && isMessageLabelExpandable && !isMessageLabelExpanded) { Spacer() expandButton #if os(visionOS) @@ -572,7 +581,6 @@ public struct FluentNotification: View, TokenizedControlView { class MSFNotificationStateImpl: ControlState, MSFNotificationState { @Published var message: String? - @Published var isMultiLine: Bool = false @Published var attributedMessage: NSAttributedString? @Published var messageLineLimit: Int @Published var title: String? @@ -589,7 +597,8 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { @Published var verticalOffset: CGFloat @Published var onDismiss: (() -> Void)? @Published var swipeToDismissEnabled: Bool - @Published var overrideDismissButonWithExpandedActionButton: Bool + @Published var showExpandButtonInPlaceOfDismissButton: Bool + @Published var enableExandableMessageText: Bool /// Title to display in the action button on the trailing edge of the control. /// @@ -633,6 +642,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, @@ -643,7 +653,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { showDefaultDismissActionButton: Bool? = nil, showActionButtonAndDismissButton: Bool = false, defaultDismissButtonAction: (() -> Void)? = nil, - overrideDismissButonWithExpandedActionButton: Bool = false, + showExpandButtonInPlaceOfDismissButton: Bool = false, expandButtonAction: (() -> Void)? = nil, messageButtonAction: (() -> Void)? = nil, swipeToDismissEnabled: Bool = false, @@ -666,10 +676,10 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState { self.showActionButtonAndDismissButton = showActionButtonAndDismissButton self.swipeToDismissEnabled = swipeToDismissEnabled self.defaultDismissButtonAction = defaultDismissButtonAction - self.overrideDismissButonWithExpandedActionButton = overrideDismissButonWithExpandedActionButton + self.showExpandButtonInPlaceOfDismissButton = showExpandButtonInPlaceOfDismissButton self.expandButtonAction = expandButtonAction self.verticalOffset = verticalOffset - + self.enableExandableMessageText = enableExandableMessageText super.init() } } From 494fae7ef5b0729fccc51d9d377036cc7fa51a2d Mon Sep 17 00:00:00 2001 From: Des Marks Date: Tue, 31 Mar 2026 15:38:14 -0700 Subject: [PATCH 4/5] Updates --- .../Notification/FluentNotification.swift | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift index 0d8baa2070..15d2131dad 100644 --- a/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift +++ b/Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift @@ -64,7 +64,7 @@ import SwiftUI /// If dismiss button should be replaced an expand button var showExpandButtonInPlaceOfDismissButton: Bool { get set } - /// Optional override for the action to be called by the expand button on the trailing edge of the control. If left nil, default action will occur. + /// Action to be taken when `showExpandButtonInPlaceOfDismissButton` is enabled var expandButtonAction: (() -> Void)? { get set } /// Action to be dispatched by tapping on the toast/bar notification. @@ -120,7 +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: Optional custom action to be dispatched by the expand button. If not provided, the default behavior expands the message to show all lines. + /// - 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. @@ -221,24 +221,36 @@ public struct FluentNotification: View, TokenizedControlView { @ViewBuilder var messageLabel: some View { if let attributedMessage = state.attributedMessage { - ExpandableText( - attributedMessage, - lineLimit: state.messageLineLimit, - isExpanded: $isMessageLabelExpanded, - onExpandabilityChange: { isExpandable in - isMessageLabelExpandable = isExpandable - } - ) + 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 { - ExpandableText( - message, - lineLimit: state.messageLineLimit, - isExpanded: $isMessageLabelExpanded, - font: tokenSet[.regularTextFont].uiFont, - onExpandabilityChange: { isExpandable in - isMessageLabelExpandable = isExpandable - } - ) + 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)) + } } } From c5960792565c65948f2c36d28a87274288662596 Mon Sep 17 00:00:00 2001 From: Des Marks Date: Tue, 31 Mar 2026 15:58:49 -0700 Subject: [PATCH 5/5] Updates --- .../ExpandableText/ExpandableText.swift | 131 +++++++----------- 1 file changed, 50 insertions(+), 81 deletions(-) diff --git a/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift index 1fa45ec153..99c04cf6a0 100644 --- a/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift +++ b/Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift @@ -11,61 +11,8 @@ import SwiftUI /// 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). /// -/// Example usage: -/// ```swift -/// @State private var isExpanded = false -/// -/// ExpandableText( -/// "Long message that may be truncated...", -/// lineLimit: 3, -/// isExpanded: $isExpanded, -/// onExpandabilityChange: { isExpandable in -/// showExpandButton = isExpandable -/// } -/// ) -/// ``` public struct ExpandableText: View { - // MARK: - Properties - - private let text: String - private let attributedText: NSAttributedString? - private let lineLimit: Int - private let font: UIFont? - private let onExpandabilityChange: ((Bool) -> Void)? - - // MARK: - State - - @State private var internalIsExpanded: Bool = false - @State private var isExpandable: Bool = false - @State private var availableWidth: CGFloat = 0 - - private var externalIsExpanded: Binding? - - // MARK: - Computed Properties - - /// 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: - Initializers + // MARK: - Public Initializers /// Creates an expandable text view with plain text. /// - Parameters: @@ -109,16 +56,9 @@ public struct ExpandableText: View { self.onExpandabilityChange = onExpandabilityChange } - // MARK: - Body + // MARK: - Public Properties public var body: some View { - contentText - } - - // MARK: - Private Views - - @ViewBuilder - private var contentText: some View { Group { if let attributed = attributedText { Text(AttributedString(attributed)) @@ -136,6 +76,42 @@ public struct ExpandableText: View { } } + // 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. @@ -143,29 +119,22 @@ public struct ExpandableText: View { private func calculateTruncation(availableWidth: CGFloat) { guard availableWidth > 0 else { return } - // Calculate the full height the text would take without line limit - let fullHeight: CGFloat - if let attributed = attributedText { - // For attributed text, use the string content with the effective font as approximation - fullHeight = attributed.string.preferredSize( - for: effectiveFont, - width: availableWidth, - numberOfLines: 0 - ).height + let messageText: String + if let attributedText { + messageText = attributedText.string } else { - fullHeight = text.preferredSize( - for: effectiveFont, - width: availableWidth, - numberOfLines: 0 - ).height + 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 -// if newExpandable != isExpandable { - isExpandable = newExpandable - onExpandabilityChange?(newExpandable) -// } + isExpandable = newExpandable + onExpandabilityChange?(newExpandable) } }