Johannes Fahrenkrug
Johannes Fahrenkrug

Reputation: 44788

How to do a "reveal"-style collapse/expand animation in SwiftUI?

I'd like to implement an animation in SwiftUI that "reveals" the content of a view to enable expand/collapse functionality. The content of the view I want to collapse and expand is complex: It's not just a simple box, but it's a view hierarchy of dynamic height and content, including images and text.

I've experimented with different options, but it hasn't resulted in the desired effect. Usually what happens is that when I "expand", the whole view was shown right away with 0% opacity, then gradually faded in, with the buttons under the expanded view moving down at the same time. That's what happened when I was using a conditional if statement that actually added and removed the view. So that makes sense.

I then experimented with using a frame modifier: .frame(maxHeight: isExpanded ? .infinity : 0). But that resulted in the contents of the view being "squished" instead of revealed.

I made a paper prototype of what I want:

paper prototype

Any ideas on how to achieve this?

Upvotes: 92

Views: 18450

Answers (4)

Echo Lumaque
Echo Lumaque

Reputation: 51

Check out DisclosureGroup. I believe this can suit your requirements: https://developer.apple.com/documentation/swiftui/disclosuregroup

Upvotes: -2

K. Khushnidjon
K. Khushnidjon

Reputation: 11

import SwiftUI

struct TaskViewCollapsible: View {
    @State private var isDisclosed = false
    let header: String = "Review Page"
    let url: String
    let tasks: [String]
    
    var body: some View {
        VStack {
            HStack {
                VStack(spacing: 5) {
                    Text(header)
                        .font(.system(size: 22, weight: .semibold))
                        .foregroundColor(.black)
                        .padding(.top, 10)
                        .padding(.horizontal, 20)
                        .frame(maxWidth: .infinity, alignment: .leading)
                    
                    Text(url)
                        .font(.system(size: 12, weight: .regular))
                        .foregroundColor(.black.opacity(0.4))
                        .padding(.horizontal, 20)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
                
                Spacer()
                
                Image(systemName: self.isDisclosed ? "chevron.up" : "chevron.down")
                    .padding(.trailing)
                    .padding(.top, 10)
                    
            }
            .onTapGesture {
                withAnimation {
                    isDisclosed.toggle()
                }
            }
            
            FetchTasks()
                .padding(.horizontal, 20)
                .padding(.bottom, 5)
                .frame(height: isDisclosed ? nil : 0, alignment: .top)
                .clipped()
        }
        .background(
            RoundedRectangle(cornerRadius: 8)
                .fill(.black.opacity(0.2))
        )
        .frame(maxWidth: .infinity)
        .padding()
    }
    
    @ViewBuilder
    func FetchTasks() -> some View {
        ScrollView(.vertical, showsIndicators: true) {
            VStack {
                ForEach(0 ..< tasks.count, id: \.self) { value in
                    Text(tasks[value])
                        .font(.system(size: 16, weight: .regular))
                        .foregroundColor(.black)
                        .padding(.vertical, 0)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
            }
        }
        .frame(maxHeight: CGFloat(tasks.count) * 20)
    }
}

struct TaskViewCollapsible_Previews: PreviewProvider {
    static var previews: some View {
        TaskViewCollapsible(url: "trello.com", tasks: ["Hello", "Hello", "Hello"])
    }
}

Upvotes: 1

Meyssam
Meyssam

Reputation: 716

Consider the utilization of DisclosureGroup. The following code should be a good approach to your idea.

struct ContentView: View {
var body: some View {
    List(0...20, id: \.self) { idx in
        DisclosureGroup { 
            HStack {
                Image(systemName: "person.circle.fill")
                VStack(alignment: .leading) {
                    Text("ABC")
                    Text("Test Test")
                }
            }
            HStack {
                Image(systemName: "globe")
                VStack(alignment: .leading) {
                    Text("ABC")
                    Text("X Y Z")
                }
            }
            HStack {
                Image(systemName: "water.waves")
                VStack(alignment: .leading) {
                    Text("Bla Bla")
                    Text("123")
                }
            }
            HStack{
                Button("Cancel", role: .destructive) {}
                Spacer()
                Button("Book") {}
            }
        } label: { 
            HStack {
                Spacer()
                Text("Expand")
            }
        }
        
    }
}

The result looks like:

Result

I coded this in under 5 minutes. So of course the design can be optimized to your demands, but the core should be understandable.

Upvotes: 16

Josh Hrach
Josh Hrach

Reputation: 1396

Something like this might work. You can modify the height of what you want to disclose to be 0 when hidden or nil when not so that it'll go for the height defined by the views. Make sure to clip the view afterwards so the contents are not visible outside of the frame's height when not disclosed.

struct ContentView: View {
    @State private var isDisclosed = false
    
    var body: some View {
        VStack {
            Button("Expand") {
                withAnimation {
                    isDisclosed.toggle()
                }
            }
            .buttonStyle(.plain)
            
            
            VStack {
                GroupBox {
                    Text("Hi")
                }
                
                GroupBox {
                    Text("More details here")
                }
            }
            .frame(height: isDisclosed ? nil : 0, alignment: .top)
            .clipped()
            
            HStack {
                Text("Cancel")
                Spacer()
                Text("Book")
            }
        }
        .frame(maxWidth: .infinity)
        .background(.thinMaterial)
        .padding()
    }
}

No, this wasn't trying to match your design, either. This was just to provide a sample way of creating the animation.

Upvotes: 32

Related Questions