Skip to content

Commit 3df2d47

Browse files
authored
[FluentNotification] Add Expand button behavior (#2260)
* Implement an 'Expand' dismiss button override
1 parent cda59b9 commit 3df2d47

6 files changed

Lines changed: 238 additions & 9 deletions

File tree

Demos/FluentUIDemo_iOS/FluentUI.Demo/Demos/NotificationViewDemoController_SwiftUI.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ struct NotificationDemoView: View {
4949
@State var showDefaultDismissActionButton: Bool = true
5050
@State var showActionButtonAndDismissButton: Bool = false
5151
@State var swipeToDismissEnabled: Bool = false
52+
@State var isExpandableMessageLabel: Bool = false
5253
@State var showFromBottom: Bool = true
5354
@State var showBackgroundGradient: Bool = false
5455
@State var useCustomTheme: Bool = false
@@ -178,6 +179,7 @@ struct NotificationDemoView: View {
178179
message: hasMessage ? message : nil,
179180
attributedMessage: hasAttribute && hasMessage ? attributedMessage : nil,
180181
messageLineLimit: messageLineLimit,
182+
enableExpandableMessageText: isExpandableMessageLabel,
181183
title: hasTitle ? title : nil,
182184
attributedTitle: hasAttribute && hasTitle ? attributedTitle : nil,
183185
image: image,
@@ -234,6 +236,7 @@ struct NotificationDemoView: View {
234236
message: hasMessage ? message : nil,
235237
attributedMessage: hasAttribute && hasMessage ? attributedMessage : nil,
236238
messageLineLimit: messageLineLimit,
239+
enableExpandableMessageText: isExpandableMessageLabel,
237240
isPresented: $isPresented,
238241
title: hasTitle ? title : nil,
239242
attributedTitle: hasAttribute && hasTitle ? attributedTitle : nil,
@@ -280,6 +283,7 @@ struct NotificationDemoView: View {
280283
LabeledContent {
281284
TextField("Line Limit", value: $messageLineLimit, formatter: integerFormatter)
282285
.keyboardType(.numberPad)
286+
.multilineTextAlignment(.trailing)
283287
} label: {
284288
Text("Message Line Limit")
285289
}
@@ -316,6 +320,8 @@ struct NotificationDemoView: View {
316320
Toggle("Can Show Action & Dismiss Buttons", isOn: $showActionButtonAndDismissButton)
317321
Toggle("Has Message Action", isOn: $hasMessageAction)
318322
Toggle("Swipe to Dismiss Enabled", isOn: $swipeToDismissEnabled)
323+
Toggle("Expandable Message Label", isOn: $isExpandableMessageLabel)
324+
.frame(maxWidth: .infinity, alignment: .leading)
319325
}
320326

321327
FluentListSection("Style") {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//
2+
// Copyright (c) Microsoft Corporation. All rights reserved.
3+
// Licensed under the MIT License.
4+
//
5+
6+
import SwiftUI
7+
8+
/// A text view that automatically detects truncation and supports expand/collapse functionality.
9+
///
10+
/// This component measures the rendered text height and determines if truncation is occurring
11+
/// based on the specified line limit. It provides an optional callback when expandability changes,
12+
/// allowing parent components to react (e.g., show/hide an expand button).
13+
///
14+
public struct ExpandableText: View {
15+
// MARK: - Public Initializers
16+
17+
/// Creates an expandable text view with plain text.
18+
/// - Parameters:
19+
/// - text: The text to display.
20+
/// - lineLimit: Maximum number of lines to show when collapsed. Default is 2.
21+
/// - isExpanded: Optional binding to control expansion state externally.
22+
/// - font: The font to use for measurement and display. If nil, uses system body font.
23+
/// - onExpandabilityChange: Callback invoked when the expandability status changes.
24+
public init(
25+
_ text: String,
26+
lineLimit: Int = 0,
27+
isExpanded: Binding<Bool>? = nil,
28+
font: UIFont? = nil,
29+
onExpandabilityChange: ((Bool) -> Void)? = nil
30+
) {
31+
self.text = text
32+
self.attributedText = nil
33+
self.lineLimit = lineLimit
34+
self.externalIsExpanded = isExpanded
35+
self.font = font
36+
self.onExpandabilityChange = onExpandabilityChange
37+
}
38+
39+
/// Creates an expandable text view with attributed text.
40+
/// - Parameters:
41+
/// - attributedText: The attributed text to display.
42+
/// - lineLimit: Maximum number of lines to show when collapsed. Default is 2.
43+
/// - isExpanded: Optional binding to control expansion state externally.
44+
/// - onExpandabilityChange: Callback invoked when the expandability status changes.
45+
public init(
46+
_ attributedText: NSAttributedString,
47+
lineLimit: Int = 0,
48+
isExpanded: Binding<Bool>? = nil,
49+
onExpandabilityChange: ((Bool) -> Void)? = nil
50+
) {
51+
self.text = attributedText.string
52+
self.attributedText = attributedText
53+
self.lineLimit = lineLimit
54+
self.externalIsExpanded = isExpanded
55+
self.font = attributedText.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
56+
self.onExpandabilityChange = onExpandabilityChange
57+
}
58+
59+
// MARK: - Public Properties
60+
61+
public var body: some View {
62+
Group {
63+
if let attributed = attributedText {
64+
Text(AttributedString(attributed))
65+
} else {
66+
Text(text)
67+
.font(font.map { Font($0) } ?? .body)
68+
}
69+
}
70+
.lineLimit(isExpanded ? nil : (lineLimit > 0 ? lineLimit : nil))
71+
.fixedSize(horizontal: false, vertical: true)
72+
.onGeometryChange(for: CGFloat.self) { geo in
73+
geo.size.width
74+
} action: { width in
75+
calculateTruncation(availableWidth: width)
76+
}
77+
}
78+
79+
// MARK: - Private Properties
80+
81+
private let text: String
82+
private let attributedText: NSAttributedString?
83+
private let lineLimit: Int
84+
private let font: UIFont?
85+
private let onExpandabilityChange: ((Bool) -> Void)?
86+
87+
@State private var isExpandable: Bool = false
88+
@State private var internalIsExpanded: Bool = false
89+
@State private var availableWidth: CGFloat = 0
90+
91+
private var externalIsExpanded: Binding<Bool>?
92+
93+
/// The current expansion state, reading from external binding if provided, otherwise using internal state.
94+
private var isExpanded: Bool {
95+
get { externalIsExpanded?.wrappedValue ?? internalIsExpanded }
96+
nonmutating set {
97+
if let binding = externalIsExpanded {
98+
binding.wrappedValue = newValue
99+
} else {
100+
internalIsExpanded = newValue
101+
}
102+
}
103+
}
104+
105+
/// The font to use for rendering and measurements, with fallback to body style.
106+
private var effectiveFont: UIFont {
107+
font ?? UIFont.preferredFont(forTextStyle: .body)
108+
}
109+
110+
/// The height of a single line of text using the effective font.
111+
private var singleLineHeight: CGFloat {
112+
ceil(effectiveFont.lineHeight + max(0, effectiveFont.leading))
113+
}
114+
115+
// MARK: - Private Methods
116+
117+
/// Calculates whether the text would be truncated by comparing the full text height to the maximum allowed height.
118+
/// - Parameter availableWidth: The available width for rendering the text.
119+
private func calculateTruncation(availableWidth: CGFloat) {
120+
guard availableWidth > 0 else { return }
121+
122+
let messageText: String
123+
if let attributedText {
124+
messageText = attributedText.string
125+
} else {
126+
messageText = text
127+
}
128+
// Calculate the full height the text would take without line limit
129+
let fullHeight = messageText.preferredSize(
130+
for: effectiveFont,
131+
width: availableWidth,
132+
numberOfLines: 0
133+
).height
134+
let maxHeight = singleLineHeight * CGFloat(lineLimit < 1 ? Int.max : lineLimit)
135+
136+
let newExpandable = fullHeight > maxHeight
137+
isExpandable = newExpandable
138+
onExpandabilityChange?(newExpandable)
139+
}
140+
}

Sources/FluentUI_iOS/Components/Notification/FluentNotification.swift

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import SwiftUI
2323
/// Integer value that sets the maximum number of lines will show for a message
2424
var messageLineLimit: Int { get set }
2525

26+
/// If the message text should be expandabled to view the entire mesage if it is truncated due to the messageLineLimit
27+
var enableExpandableMessageText: Bool { get set }
28+
2629
/// Optional text to draw above the message area.
2730
var title: String? { get set }
2831

@@ -99,6 +102,8 @@ public struct FluentNotification: View, TokenizedControlView {
99102
/// - isFlexibleWidthToast: Whether the width of the toast is set based on the width of the screen or on its contents.
100103
/// - message: Optional text for the main title area of the control. If there is a title, the message becomes subtext.
101104
/// - 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.
105+
/// - messageLineLimit: The maximum number of lines the message can show. Any excess text is truncated.
106+
/// - enableExpandableMessageText: If enabled, an expand button will be shown in place of the dimiss icon when the text is truncated. Tapping the expand button will display all lines of text.
102107
/// - isPresented: Controls whether the Notification is being presented.
103108
/// - title: Optional text to draw above the message area.
104109
/// - attributedTitle: Optional attributed text to draw above the message area. If set, it will override the title parameter.
@@ -118,6 +123,7 @@ public struct FluentNotification: View, TokenizedControlView {
118123
message: String? = nil,
119124
attributedMessage: NSAttributedString? = nil,
120125
messageLineLimit: Int = 0,
126+
enableExpandableMessageText: Bool = false,
121127
isPresented: Binding<Bool>? = nil,
122128
title: String? = nil,
123129
attributedTitle: NSAttributedString? = nil,
@@ -139,6 +145,7 @@ public struct FluentNotification: View, TokenizedControlView {
139145
message: message,
140146
attributedMessage: attributedMessage,
141147
messageLineLimit: messageLineLimit,
148+
enableExpandableMessageText: enableExpandableMessageText,
142149
title: title,
143150
attributedTitle: attributedTitle,
144151
image: image,
@@ -158,7 +165,6 @@ public struct FluentNotification: View, TokenizedControlView {
158165
self.shouldSelfPresent = shouldSelfPresent
159166
self.isFlexibleWidthToast = isFlexibleWidthToast && style.isToast
160167
self.triggerModel = triggerModel
161-
162168
self.tokenSet = NotificationTokenSet(style: { state.style })
163169

164170
if let isPresented = isPresented {
@@ -204,11 +210,36 @@ public struct FluentNotification: View, TokenizedControlView {
204210
@ViewBuilder
205211
var messageLabel: some View {
206212
if let attributedMessage = state.attributedMessage {
207-
Text(AttributedString(attributedMessage))
208-
.fixedSize(horizontal: false, vertical: true)
213+
if state.enableExpandableMessageText {
214+
ExpandableText(
215+
attributedMessage,
216+
lineLimit: state.messageLineLimit,
217+
isExpanded: $isMessageLabelExpanded,
218+
onExpandabilityChange: { isExpandable in
219+
isMessageLabelExpandable = isExpandable
220+
}
221+
)
222+
} else {
223+
Text(AttributedString(attributedMessage))
224+
.lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil)
225+
.fixedSize(horizontal: false, vertical: true)
226+
}
209227
} else if let message = state.message {
210-
Text(message)
211-
.font(.init(tokenSet[.regularTextFont].uiFont))
228+
if state.enableExpandableMessageText {
229+
ExpandableText(
230+
message,
231+
lineLimit: state.messageLineLimit,
232+
isExpanded: $isMessageLabelExpanded,
233+
font: tokenSet[.regularTextFont].uiFont,
234+
onExpandabilityChange: { isExpandable in
235+
isMessageLabelExpandable = isExpandable
236+
}
237+
)
238+
} else {
239+
Text(message)
240+
.lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil)
241+
.font(.init(tokenSet[.regularTextFont].uiFont))
242+
}
212243
}
213244
}
214245

@@ -218,7 +249,7 @@ public struct FluentNotification: View, TokenizedControlView {
218249
if hasSecondTextRow {
219250
titleLabel
220251
}
221-
messageLabel.lineLimit(state.messageLineLimit > 0 ? state.messageLineLimit : nil)
252+
messageLabel
222253
}
223254
.padding(.vertical, NotificationTokenSet.verticalPadding)
224255
}
@@ -266,6 +297,24 @@ public struct FluentNotification: View, TokenizedControlView {
266297
}
267298
}
268299

300+
@ViewBuilder
301+
var expandButton: some View {
302+
HStack {
303+
SwiftUI.Button(action: {
304+
if state.enableExpandableMessageText {
305+
// Default behavior: toggle expansion state
306+
isMessageLabelExpanded.toggle()
307+
} else {
308+
preconditionFailure("FluentNotification expandButton should not be rendered given state")
309+
}
310+
}, label: {
311+
Image("chevron-up-20x20", bundle: FluentUIFramework.resourceBundle)
312+
.accessibilityLabel("Accessibility.Expand.Label".localized)
313+
})
314+
.hoverEffect()
315+
}
316+
}
317+
269318
let messageButtonAction = state.messageButtonAction
270319
@ViewBuilder
271320
var innerContents: some View {
@@ -296,13 +345,22 @@ public struct FluentNotification: View, TokenizedControlView {
296345
.buttonStyle(.borderless)
297346
#endif // os(visionOS)
298347
.layoutPriority(1)
299-
if dismissButtonAction != nil {
348+
if state.enableExpandableMessageText && isMessageLabelExpandable && !isMessageLabelExpanded {
300349
Spacer()
301-
dismissButton
350+
expandButton
302351
#if os(visionOS)
303352
.buttonStyle(.borderless)
304353
#endif // os(visionOS)
305354
.layoutPriority(1)
355+
} else {
356+
if dismissButtonAction != nil {
357+
Spacer()
358+
dismissButton
359+
#if os(visionOS)
360+
.buttonStyle(.borderless)
361+
#endif // os(visionOS)
362+
.layoutPriority(1)
363+
}
306364
}
307365
}
308366
.onSizeChange { newSize in
@@ -504,6 +562,8 @@ public struct FluentNotification: View, TokenizedControlView {
504562
@State private var attributedTitleSize: CGSize = CGSize()
505563
@State private var opacity: CGFloat = 0
506564
@State private var bumpVerticalOffset: CGFloat = 0
565+
@State private var isMessageLabelExpandable = false
566+
@State private var isMessageLabelExpanded = false
507567

508568
// When true, the notification view will take up all proposed space
509569
// and automatically position itself within it.
@@ -535,6 +595,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState {
535595
@Published var verticalOffset: CGFloat
536596
@Published var onDismiss: (() -> Void)?
537597
@Published var swipeToDismissEnabled: Bool
598+
@Published var enableExpandableMessageText: Bool
538599

539600
/// Title to display in the action button on the trailing edge of the control.
540601
///
@@ -566,6 +627,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState {
566627
actionButtonAction: nil,
567628
showDefaultDismissActionButton: nil,
568629
showActionButtonAndDismissButton: false,
630+
defaultDismissButtonAction: nil,
569631
messageButtonAction: nil,
570632
swipeToDismissEnabled: false,
571633
showFromBottom: true,
@@ -576,6 +638,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState {
576638
message: String? = nil,
577639
attributedMessage: NSAttributedString? = nil,
578640
messageLineLimit: Int = 0,
641+
enableExpandableMessageText: Bool = false,
579642
title: String? = nil,
580643
attributedTitle: NSAttributedString? = nil,
581644
image: UIImage? = nil,
@@ -608,7 +671,7 @@ class MSFNotificationStateImpl: ControlState, MSFNotificationState {
608671
self.swipeToDismissEnabled = swipeToDismissEnabled
609672
self.defaultDismissButtonAction = defaultDismissButtonAction
610673
self.verticalOffset = verticalOffset
611-
674+
self.enableExpandableMessageText = enableExpandableMessageText
612675
super.init()
613676
}
614677
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
chevron-up-20x20.imageset
12
dismiss-20x20.imageset
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"images": [
3+
{
4+
"filename": "chevron-up-20x20.svg",
5+
"idiom": "universal"
6+
}
7+
],
8+
"info": {
9+
"author": "xcode",
10+
"version": 1
11+
},
12+
"properties": {
13+
"preserves-vector-representation": true,
14+
"template-rendering-intent": "template"
15+
}
16+
}
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)