Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -178,6 +179,7 @@ struct NotificationDemoView: View {
message: hasMessage ? message : nil,
attributedMessage: hasAttribute && hasMessage ? attributedMessage : nil,
messageLineLimit: messageLineLimit,
enableExandableMessageText: expandButtonMode == 1,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: enableExpandableMessageText

title: hasTitle ? title : nil,
attributedTitle: hasAttribute && hasTitle ? attributedTitle : nil,
image: image,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -280,6 +287,7 @@ struct NotificationDemoView: View {
LabeledContent {
TextField("Line Limit", value: $messageLineLimit, formatter: integerFormatter)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
} label: {
Text("Message Line Limit")
}
Expand Down Expand Up @@ -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") {
Expand Down
140 changes: 140 additions & 0 deletions Sources/FluentUI_iOS/Components/ExpandableText/ExpandableText.swift
Original file line number Diff line number Diff line change
@@ -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<Bool>? = 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<Bool>? = 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<Bool>?

/// 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) {
Comment thread
joannaquu marked this conversation as resolved.
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)
}
}
Loading
Loading