diff --git a/ElementX/Sources/Other/Extensions/AttributedString.swift b/ElementX/Sources/Other/Extensions/AttributedString.swift index 40cb4d9bb..9e087cd84 100644 --- a/ElementX/Sources/Other/Extensions/AttributedString.swift +++ b/ElementX/Sources/Other/Extensions/AttributedString.swift @@ -37,7 +37,8 @@ extension AttributedString { .plainText } - components.append(AttributedStringBuilderComponent(attributedString: attributedString, + components.append(AttributedStringBuilderComponent(id: String(attributedString.characters), + attributedString: attributedString, type: componentType)) } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index 6b1c720b3..6e51689cc 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -8,13 +8,15 @@ import Foundation -struct AttributedStringBuilderComponent: Hashable { +struct AttributedStringBuilderComponent: Hashable, Identifiable { enum ComponentType { case plainText case blockquote case codeBlock } + /// Identifier for the `Identifiable` conformance, allows edits to the `FormattedBodyText` to animate seamlessly + let id: String let attributedString: AttributedString let type: ComponentType } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift index 52b7f8b19..9fffd9035 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FormattedBodyText.swift @@ -68,13 +68,16 @@ struct FormattedBodyText: View { // timestamp would land on top of it, so append an empty trailing plain-text // component that exists solely to reserve space for the timestamp underneath. if trailingReservedSize != .zero, let last = components.last, last.type != .plainText { - components.append(AttributedStringBuilderComponent(attributedString: AttributedString(), + components.append(AttributedStringBuilderComponent(id: "", + attributedString: AttributedString(), type: .plainText)) } let lastPlainTextIndex = components.lastIndex { $0.type == .plainText } return TimelineBubbleLayout(spacing: 8) { - ForEach(Array(components.enumerated()), id: \.offset) { index, component in + // The `ForEach` needs to iterate over the id of the element to allow + // SwiftUI animations to work properly ater any edit. + ForEach(Array(components.enumerated()), id: \.element.id) { index, component in switch component.type { case .blockquote: BlockquoteView(attributedString: component.attributedString, mode: .rendering) @@ -98,7 +101,7 @@ struct FormattedBodyText: View { // Make a second iteration through the components adding naturally sized versions of the // block quotes and code blocks which are used for layout calculations but won't be rendered. - ForEach(Array(components.enumerated()), id: \.offset) { _, component in + ForEach(components) { component in switch component.type { case .blockquote: BlockquoteView(attributedString: component.attributedString, mode: .layout)