8

I'm attempting to construct an animation using SwiftUI.

Start: [ A ][ B ][ D ]
End:   [ A ][ B ][    C    ][ D ]

The key elements of the animation are:

  • C should appear to slide out from underneath B (not expand from zero width)
  • The widths of all views are defined by subviews, and are not known
  • The widths of all subviews should not change during or after the animation (so, total view width is larger when in the end state)

I'm having a very difficult time satisfying all of these requirements with SwiftUI, but have been able to achieve similar affects with auto-layout in the past.

My first attempt was a transition using an HStack with layoutPriorities. This didn't really come close, because it affects the width of C during the animation.

My second attempt was to keep the HStack, but use a transition with asymmetrical move animations. This came really close, but the movement of B and C during the animation does not give the effect that C was directly underneath B.

My latest attempt was to scrap relying on an HStack for the two animating views, and use a ZStack instead. With this setup, I can get my animation perfect by using a combination of offset and padding. However, I can only get it right if I make the frame sizes of B and C known values.

Does anyone have any ideas on how to achieve this effect without requiring fixed frame sizes for B and C?

Mattie
  • 2,868
  • 2
  • 25
  • 40
  • WWDC session "Introducing SwiftUI: Building Your First App" has an example of two views in a ZStack where one slides in and out. Maybe it will help you. – koen Jun 17 '19 at 16:44
  • I'm as new as anyone to SwiftUI, but seems to me that if you want automatic sizing, you want to keep HStack involved. Is it possible to contain C in a group or something and have that translate in? – Drew McCormack Jun 24 '19 at 09:56
  • Seems like you probably want a group container view for C that is fixed to the size of the available cell, and move it outside the area allotted in the HStack, animating it in. So you would need a way to have a child view shifted outside its parent, and clipped. Maybe that is an avenue of investigation. – Drew McCormack Jun 24 '19 at 10:04
  • I solved a similar problem using .layoutPriority() and .offset(). I had a view sized by its children which jumped on transition out because it suddenly became zero width as the children were pulled out of the view hierarchy. Use .layoutPriority(cIsVisible ? x : y) on children of a ZStack to choose which child decides the size of the parent, introducing dummy children if needed. Then use .offset(cIsVisible ? x : y) on A, B, C, and D as needed to fix any positional errors during transition. – Dan Halliday Jun 24 '19 at 15:54
  • @DanHalliday I've tried this. Where I struggle is figuring out what value to use for the offset, since my views' sizes are not known. – Mattie Jun 24 '19 at 16:09

2 Answers2

13

Since I originally replied to this question, I have been investigating GeometryReader, View Preferences and Anchor Preferences. I have assembled a detailed explanation that elaborates further. You can read it at: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

enter image description here

Once you get the CCCCCCCC view geometry into the textRect variable, the rest is easy. You simply use the .offset(x:) modifier and clipped().

import SwiftUI

struct RectPreferenceKey: PreferenceKey {
    static var defaultValue = CGRect()

    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }

    typealias Value = CGRect
}

struct ContentView : View {
    @State private var textRect = CGRect()
    @State private var slideOut = false

    var body: some View {

        return VStack {
            HStack(spacing: 0) {
                Text("AAAAAA")
                    .font(.largeTitle)
                    .background(Color.yellow)
                    .zIndex(4)


                Text("BBBB")
                    .font(.largeTitle)
                    .background(Color.red)
                    .zIndex(3)

                Text("I am a very long text")
                    .zIndex(2)
                    .font(.largeTitle)
                    .background(GeometryGetter())
                    .background(Color.green)
                    .offset(x: slideOut ? 0.0 : -textRect.width)
                    .clipped()
                    .onPreferenceChange(RectPreferenceKey.self) { self.textRect = $0 }

                Text("DDDDDDDDDDDDD").font(.largeTitle)
                    .zIndex(1)
                    .background(Color.blue)
                    .offset(x: slideOut ? 0.0 : -textRect.width)

            }.offset(x: slideOut ? 0.0 : +textRect.width / 2.0)

            Divider()
            Button(action: {
                withAnimation(.basic(duration: 1.5)) {
                    self.slideOut.toggle()
                }
            }, label: {
                Text("Animate Me")
            })
        }

    }
}

struct GeometryGetter: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle()
                .fill(Color.clear)
                .preference(key: RectPreferenceKey.self, value:geometry.frame(in: .global))
        }
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • If you would like to keep everything centered, just add the following to the HStack container: ```HStack { ... }.offset(x: slideOut ? 0.0 : +textRect.width / 2.0)```. – kontiki Jun 24 '19 at 14:55
  • This is an absolutely fascinating approach. It does get the information I was looking for. However, I'm not 100% sure that the implementation of GeometryGetter is safe. Still thinking about it - especially since it does seem to produce warnings sometimes. – Mattie Jun 24 '19 at 15:40
  • What type of warnings are you getting? – kontiki Jun 24 '19 at 15:41
  • 1
    Ok, I changed the code to make it 100% safe. I suspect you did not like the DispatchQueue.main.async call (to be honest, I didn't either). I got another little trick for that. Use **Preferences** instead. Their documentation is basically inexistent, but I made them work somehow. – kontiki Jun 24 '19 at 16:15
  • Two very creative approaches! You got it, that the main.async was giving me pause. I'm going to try this out. Thank you! – Mattie Jun 24 '19 at 17:44
  • 1
    There's another interesting tool, but so far I could not figure it out. Anchor preferences seem to be a gold mine of geometry properties, but is not at all documented. – kontiki Jun 24 '19 at 17:51
  • 2
    I updated my answer, since I solved the problem of the C view been larger than the sum of A and B. It turned out we only needed to add .clipped() just after .offset(). – kontiki Jul 05 '19 at 09:50
  • Thanks for the great demo. I'd like to point out that the approach has a serious limitation which prevents it from being applied in general case - when the third subview is hided it still contribute to the size of its parent view. On the other hand, however, I haven't figured out a way to implement the animation by using `offset()` transition (I doubt if it's feasible). I know the answer was given shortly after SwiftUI was released. It seems there isn't much change in this regard three years later? – rayx May 22 '22 at 01:24
4

It's hard to tell what exactly you're going for or what's not working. It would be easier to help you if you showed the "wrong" animation you came up with or shared your code.

Anyway, here's a take. I think it sort of does what you specified, though it's certainly not perfect:

Animated GIF of my solution

Observations:

  • The animation relies on the assumptions that (A) and (B) together are wider than (C). Otherwise, parts of (C) would appear to the left of A at the start of the animation.

  • Similarly, the animation relies on the fact that there's no spacing between the views. Otherwise, (C) would be appear to the left of (B) when it's wider than (B).

    It may be possible to solve both problems by placing an opaque underlay view in the hierarchy such that it is below (A), (B), and (D), but above (C). But I haven't thought this through.

  • The HStack seems to expand a tad more quickly than (C) is sliding in, which is why a white portion appears briefly. I didn't manage to eliminate this. I tried adding the same animation(.basic()) modifier to the HStack, the transition, the withAnimation call, and the VStack, but that didn't help.

The code:

import SwiftUI

struct ContentView: View {
  @State var thirdViewIsVisible: Bool = false

  var body: some View {
    VStack(alignment: .leading, spacing: 20) {
      HStack(spacing: 0) {
        Text("Lorem ").background(Color.yellow)
          .zIndex(1)
        Text("ipsum ").background(Color.red)
          .zIndex(1)
        if thirdViewIsVisible {
          Text("dolor sit ").background(Color.green)
            .zIndex(0)
            .transition(.move(edge: .leading))
        }
        Text("amet.").background(Color.blue)
          .zIndex(1)
      }
        .border(Color.red, width: 1)
      Button(action: { withAnimation { self.thirdViewIsVisible.toggle() } }) {
        Text("Animate \(thirdViewIsVisible ? "out" : "in")")
      }
    }
      .padding()
      .border(Color.green, width: 1)
  }
}
Ole Begemann
  • 135,006
  • 31
  • 278
  • 256
  • Thanks for this. I'm sorry my question wasn't clearer. What you've done is basically my second attempt. If you add some transparency to B and slow down the animation, you'll see why the HStack spacing is funny. C's starting position isn't perfect. Perhaps that is controllable somehow? – Mattie Jun 24 '19 at 12:03
  • 1
    You should really add an animated GIF to your question that shows exactly what you mean by "the HStack spacing is funny". – Ole Begemann Jun 24 '19 at 12:41