Home

Awesome

Foldable And Expandable List SwiftUI

This project is a demonstration of how to add simultaneous animations of folding and expanding cells while scrolling. In the project, I put a lot of comments explaining the code behind this. It's by no means perfect and it is my hope that others in the community can perfect the animations I've laid out.

This project is inspired by TimePage and is part of a larger repository of elegant demonstrations like this: TimePage Clone.

Also, make sure to check out TimePrints, an app that I'm almost done working on that utilizes this foldable scrolling animations as well as other slick SwiftUI animations.

The animation is actually much smoother if you run it yourself

<img src="https://github.com/ThasianX/GIFs/blob/master/Foldable-And-Expandable-List/demo.gif" height="600"/>

How this works

Basically the ExpandAndFoldModifier is applied to every row in the list, wrapping each row in a GeometryReader which tells the compiler information about the minY position of the row. This will be crucial later on for determining when the row should be folded and the expanding animation's offset. It looks like this:

struct ExpandAndFoldModifier: ViewModifier {

  func body(content: Content) -> some View {
      GeometryReader { geometry in
          content
              .modifier(self.makeNestedModifier(withMinY: geometry.frame(in: .global).minY))
      }
  }

  private func makeNestedModifier(withMinY minY: CGFloat) -> _ExpandAndFoldModifier {
      _ExpandAndFoldModifier(
      rowHeight: rowHeight, // height of the row to be folded
      foldOffset: foldOffset, // y coordinate at which to start folding
      minY: minY, // the current y coordinate of the row
      shouldFold: shouldFold, // shouldn't fold when expanded
      isActiveIndex: isActiveIndex) // if the row is active, we want to expand it by offsetting it from it's current position to the top of the screen
  }
  
}

In order to reduce duplication of calling geometry.frame(in: .global).minY, a nested modifier is created which accepts minY as a parameter.

The body of the nested modifier looks like this:

func body(content: Content) -> some View {
    content
        .offset(y: isActiveIndex ? topOfScreen : 0)
        .rotation3DEffect(rotationAngle, axis: (x: -200, y: 0, z: 0), anchor: .bottom)
        .opacity(opacity)
}

There are 3 components here:

Offset:

If the row has just been tapped, isActiveIndex should be true and the row expands through animating the offset from its current position(y=minY) to the top of the screen(y=0). That offset is just -minY, which is exactly what the topOfScreen variable value is. The other rows that aren't active remain where they are.

Rotation:

If you look at gif, you'll notice that the rows are being folded into the back of the screen: this is a result of anchoring the rotation3DEffect to the bottom of the row and setting the rotation axis to x=-200. The x axis is the horizontal axis of the screen: +x folds towards the front of the screen, -x folds into the back of the screen, as shown in the gif. The number inside the x doesn't matter as long as it's negative(this however isn't the case if when y or z have values because then, the x value plays a more significant role in the axis). Play around with the values to understand).

The most important part of this is the rotationAngle. Here's the code:

private var rotationAngle: Angle {
    guard shouldFold && shouldStartFolding else { return .degrees(0) }
    return .degrees(-foldDegree) // negative because we want to fold inward
}

private var shouldStartFolding: Bool {
    minY < foldOffset
}

private var foldDegree: Double {
    // When the minY of the provided cell is equal to the foldOffset, the
    // fold degree should be 0 and as such, the fold delta is 1.
    // fold degree becomes 90 when fold delta becomes 0, which is when
    // the cell is completely folded
    guard foldDelta >= 0 else { return 90 }
    return 90 - (90 * foldDelta)
}

private var foldDelta: Double {
    Double((rowHeight + (minY - foldOffset)) / rowHeight)
}

The rotationAngle is 0 degrees when either shouldFold is false(this is false only when a row is expanded) or the current minY position of the row is above foldOffset position. If these 2 checks pass, this means that the row is within fold range and thus the foldDegree can be calculated through foldDegree = 90 - (90 * foldDelta). When minY is equal to the foldOffset, the fold degree should be 0 and as such, the fold delta is 1. foldDegree becomes 90 when foldDelta becomes 0, which is when the cell is completely folded .

foldDelta was derived through 2 sources of truth. When minY is greater than or equal to foldOffset, there shouldn't be any fold. This is accounted for in the rotationAngle's guard. When minY is less than or equal to foldOffset - rowHeight, the foldDegree should be capped at 90 degrees, meaning that the row is folded. This is accounted for in foldDegree's guard.

Opacity:

The code for this is pretty simple and straightforward:

private var opacity: Double {
    guard shouldFold && shouldStartFolding && (foldDelta >= 0) else { return 1 }
    // 0.4 padding because we don't want the cell to fully fade when it's folded
    return foldDelta + 0.4
}

The only case that is of concern is when the row is being folded. The opacity is made to be linearly proportional to foldDelta because as the row is folded more, foldDelta approaches 0, signifying a dim in opacity for the folded row.

Contributing

Resources

License

This project is licensed under the MIT License - see the LICENSE file for details