I'm trying to replicate an accordion component that should work like the one in the Custom Control Label from this documentation. To do this I created a Collapsable view as follows:
struct Collapsable<Content>: View where Content: View {
internal var content: () -> Content
@Binding private var isCollapsed: Bool
@State private var isCollapsedForAnimation: Bool
init(isCollapsed: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) {
self._isCollapsed = isCollapsed
self.content = content
self.isCollapsedForAnimation = isCollapsed.wrappedValue
}
var body: some View {
content()
.background(.ultraThinMaterial)
.frame(maxHeight: isCollapsedForAnimation ? 0 : nil, alignment: .top)
.contentShape(Rectangle())
.clipped()
.onChange(of: self.isCollapsed) { isCollapsed in
withAnimation {
self.isCollapsedForAnimation = isCollapsed
}
}
}
}
And it works as in it allows me to show and hide the content of the content
parameter, but the animation is terribly inconsistent, since they way it expands/contracts depends on the container. I would like the view to always expand and contract from bottom to top, or even better, allow the user to specify whether he wants it to animate from top to bottom or the other way around.
Unfortunately I couldn't find a way to get this behavior, so I'm looking for hints in the right direction.
The first thing to understand is that you must test on a real device, not on the simulator. The iOS simulator often has display bugs that real devices don't.
The second thing to understand is that SwiftUI animations can be flaky and difficult even on devices. 🤷🏼
Anyway, let's reproduce the accordion component you linked to, in SwiftUI. Here's my result:
I recorded this on my iPhone 12 Mini running iOS 16.4. On the device it looks much smoother, but I generated the animated GIF at 15 fps to get it down to an almost reasonable file size.
Anyway, here's my implementation of CustomControlLabel
:
struct CustomControlLabel<Header: View, Content: View>: View {
@Binding
var isExpanded: Bool
let header: Header
let content: Content
init(
isExpanded: Binding<Bool>,
@ViewBuilder header: () -> Header,
@ViewBuilder content: () -> Content
) {
_isExpanded = isExpanded
self.header = header()
self.content = content()
}
var body: some View {
VStack(spacing: 0) {
HStack {
header
Spacer()
Image(systemName: "chevron.down")
.font(.caption2)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
}
.contentShape(Rectangle())
.onTapGesture { isExpanded.toggle() }
content
.opacity(isExpanded ? 1 : 0)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 16)
.frame(height: isExpanded ? nil : 0, alignment: .top)
.clipped()
}
.padding()
.padding(1)
.border(Color.gray, width: 1)
.background {
if isExpanded {
Color.black.opacity(0.05)
}
}
.padding(-0.5)
}
}
Here are two key differences between my code and yours:
I use the fixedSize(horizontal: false, vertical: true)
modifier on the collapsing content, inside the frame
modifier that sets its height to zero when it's collapsed. This fixedSize
modifier hides the zero height from the content, so it doesn't try to change its own layout to fit in a zero height.
I don't perform the animation at this level. Expanding one CustomControlLabel
should simultaneously collapse any other expanded CustomControlLabel
. To get the overall layout to animate correctly, I apply an animation
modifier to the container that contains all of the CustomControlLabel
s.
It's also important that the VStack
in there has spacing: 0
. The default is that SwiftUI picks a spacing it thinks is appropriate, but that spacing changes when the content is entirely collapsed! So without the spacing: 0
, the collapsible content moves slightly as its visibility changes. To prevent that, I set spacing: 0
and added padding(.top, 16)
to the collapsible content.
Here is the rest of my code:
struct ToonHeader: View {
let pic: UIImage
let name: String
let blurb: String
var body: some View {
HStack {
Image(uiImage: pic)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64, height: 64)
VStack(alignment: .leading) {
Text(name)
Text(blurb)
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
enum Selection: Equatable {
case bender
case mom
case homer
}
struct CustomControlLabelTestView: View {
@State
var selection: Selection? = nil
func binding(for target: Selection) -> Binding<Bool> {
return .init(
get: { selection == target },
set: {
if $0 {
selection = target
} else if selection == target {
selection = nil
}
}
)
}
var body: some View {
VStack(spacing: 0) {
CustomControlLabel(isExpanded: binding(for: .bender)) {
ToonHeader(
pic: #imageLiteral(resourceName: "futurama-bender.png"),
name: "Bender Bending Rodríguez",
blurb: "Fascinated with cooking, though has no sense of taste"
)
} content: {
Text("Bender Bending Rodríguez (born September 4, 2996), designated Bending Unit 22, and commonly known as Bender, is a bending unit created by a division of MomCorp in Tijuana, Mexico, and his serial number is 2716057. His mugshot id number is 01473. He is Fry's best friend.")
.font(.caption)
}
CustomControlLabel(isExpanded: binding(for: .mom)) {
ToonHeader(
pic: #imageLiteral(resourceName: "futurama-mom.png"),
name: "Carol Miller",
blurb: "One of the richest people on Earth"
)
} content: {
Text("Carol Miller (born January 30, 2880), better known as Mom, is the evil chief executive officer and shareholder of 99.7% of Momcorp, one of the largest industrial conglomerates in the universe and the source of most of Earth's robots. She is also one of the main antagonists of the Futurama series.")
.font(.caption)
}
CustomControlLabel(isExpanded: binding(for: .homer)) {
ToonHeader(
pic: #imageLiteral(resourceName: "homer-simpson.png"),
name: "Homer Simpson",
blurb: "Overweight, lazy, and often ignorant"
)
} content: {
Text("Homer Jay Simpson (born May 12) is the main protagonist and one of the five main characters of The Simpsons series(or show). He is the spouse of Marge Simpson and father of Bart, Lisa and Maggie Simpson.")
.font(.caption)
}
Spacer()
}
.animation(.easeInOut(duration: 1/6), value: selection)
.padding()
}
}
You'll need to scroll down but at the bottom you'll find the single animation
modifier that animates everything.