SwuiftUI collapse multiline Text

Viewed 142

I'm trying to add an expand/colapse animation on a Text with multiple lines, and I'm having a strange behaviour.

Below is a gif with the issue. I've set slow animations to make it clear.

https://www.dropbox.com/s/sx41g9tfx4hd378/expand-collapse-stack_overflow.gif

I'm animating the height property of the view, and it seems that the Text will convert immediately to one line disregarding the animation period. Here's some code:

struct ContentView: View {

    @State var expanded = false

    var body: some View {
        VStack(spacing: 20) {
            HStack {
                Button(expanded ? "Colapse" : "Expand") {
                    withAnimation {
                        self.expanded.toggle()
                    }
                }
            }
            VStack(spacing: 10) {
                Text(bigText)
                Text(bigText)
            }
            .frame(height: expanded ? .none : 0)
            .clipped()
            .background(Color.red)
            Text("Thist is another text underneath the huge one. ")
                .font(.system(.headline))
                .foregroundColor(.red)
            Spacer()
        }
    }
}

I have tried a lot of other things, and this is currently the closest to the desired output, which is the same as animating a label inside a UIStackView in UIKit.

Is there a way to do this properly? Is this a bug? Normally the problem is from the developer, but I noticed that if I use a DisclosureGroup the animation works when it's expanding, but when it's collapsing it simply has no animation. So this might actually be a limitation of multi line Text?

Thank you very much.

1 Answers

Well, the problem is that we animate frame between nil and 0, but to internal Text items only edge values are transferred.

To solve this it should be done two steps:

  1. make height animatable data, so every change to height value passed to content
  2. calculate real max height of text content, because we need concrete values of animatable range.

So here is a demo of approach. Prepared with Xcode 13 / iOS 15

Note: slow animation is activated in Simulator for better visibility

enter image description here

let bigText = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
"""

struct ContentView: View {

    // we need `true` on construction (not visible) to calculate
    // max content height
    @State var expanded = true    // << required initial true !!

    @State private var maxHeight: CGFloat?

    var body: some View {
        VStack(spacing: 20) {
            HStack {
                Button(expanded ? "Colapse" : "Expand") {
                    withAnimation {
                        self.expanded.toggle()
                    }
                }
            }
            VStack(spacing: 10) {
                Text(bigText)
                Text(bigText)
            }
            .background(GeometryReader { // read content height
                Color.clear.preference(key: ViewHeightKey.self,
                                              value: $0.frame(in: .local).size.height)
            })
            .onPreferenceChange(ViewHeightKey.self) {
                if nil == self.maxHeight {
                    self.maxHeight = $0     // << needed once !!
                }
            }
            .modifier(AnimatingFrameHeight(height: expanded ? maxHeight ?? .infinity : 0))
            .clipped()
            .background(Color.red)
            Text("Thist is another text underneath the huge one. ")
                .font(.system(.headline))
                .foregroundColor(.red)
            Spacer()
        }
        .onAppear {
            // this cases instance redraw on first render
            // so initial state will be invisible for user
            expanded = false    // << set if needed here !!
        }
    }
}

struct AnimatingFrameHeight: AnimatableModifier {
    var height: CGFloat = 0

    var animatableData: CGFloat {
        get { height }
        set { height = newValue }
    }

    func body(content: Content) -> some View {
        content.frame(height: height)
    }
}

struct ViewHeightKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}
Related