How do I change a transition based on changed state?

I am trying to write a simple view that displays text, two lines at a time. It must be possible for the user to move up and down in the stack of text.


In order to make it look good, the items scrolled in and out of the stack must transition in and out from the top or bottom based on the direction the user selected. Below is some test code that demonstrates the problem. It works correctly as long as you keep sliding in one direction, but fails the first time you change direction.


It almost works except for when you change direction. The problem is that the .transition is applied to the Text in line 18 based on the current slideUp state variable. It therefore has the wrong value when the state (direction of movement) changes.


Can anyone see a simple, elegant way of solving this?


import SwiftUI

struct ContentView: View {
  private struct DisplayItem {
    var id: Int
    var text: String
  }
  
  private let numVisibleItems = 2
  
  @State private var slideUp: Bool = true
  @State private var startIndex: Int = 0

  var items: [String] = []
  
  var body: some View {
    HStack {
      VStack {
        ForEach(visibleItems(), id: \DisplayItem.id) { item in
          Text("\(item.text)").font(.system(size: 60)).animation(.easeInOut)
            .transition(.asymmetric(insertion: AnyTransition.opacity.combined(with: .move(edge: !self.slideUp ? .top : .bottom )),
                                    removal: AnyTransition.opacity.combined(with: .move(edge: self.slideUp ? .top : .bottom ))))
        }
      }
      .animation(.easeInOut(duration: 1.0))
      .frame(width: 200, height: 200)
      
      VStack {
        Button(action: {
          self.slideUp = true
          self.startIndex = min(self.items.count-1, self.startIndex + 1)
        }, label: { Text("Slide Up") })
        Button(action: {
          self.slideUp = false
          self.startIndex = max(0, self.startIndex - 1)
        }, label: { Text("Slide Down") })
      }
    }
  }
  
  private func visibleItems() -> [DisplayItem] {
    let endIndex = min(startIndex + numVisibleItems - 1, items.count - 1)
    var result = [DisplayItem]()
    for i in startIndex...endIndex {
      result.append(DisplayItem(id: i, text: items[i]))
    }
    
    return result
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    let formatter = NumberFormatter()
    formatter.numberStyle = .spellOut
    let data = (0..<10).map { formatter.string(from: NSNumber(value: $0))! }
    
    return ContentView(items: data)
  }
}

Accepted Reply

Finally, with a dispatch, seems to work as intended.


If that works, thanks to close the thread, otherwise, please describe very precisely what behavior you would want to get.


struct ContentView: View {
  private struct DisplayItem {
    var id: Int
    var text: String
  }
   
  private let numVisibleItems = 3
   
  @State private var slideUp: Bool = true
  @State private var startIndex: Int = 0
 
//  var items: [String] = []
    var items: [String] = ["Hello", "You", "Boys", "Girls"]

  var body: some View {
    HStack {
      VStack {
        ForEach(visibleItems(), id: \DisplayItem.id) { item in
          Text("\(item.text)").font(.system(size: 30)).animation(.easeInOut)
            .transition(.asymmetric(
                insertion: AnyTransition.opacity.combined(with: .move(edge: !self.slideUp ? .top : .bottom )),
                removal: AnyTransition.opacity.combined(with: .move(edge: self.slideUp ? .top : .bottom ))))
        }
      }
      .animation(.easeInOut(duration: 1.0))
      .frame(width: 200, height: 200)
       
      VStack {
        Button(action: {
          self.slideUp = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.startIndex = min(self.items.count-1, self.startIndex + 1)
            }
        }, label: { Text("Slide Up") })
        Button(action: {
          self.slideUp = false
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.startIndex = max(0, self.startIndex - 1)
            }
        }, label: { Text("Slide Down") })
      }
    }
  }
   
  private func visibleItems() -> [DisplayItem] {
    let endIndex = min(startIndex + numVisibleItems - 1, items.count - 1)
    var result = [DisplayItem]()
    for i in startIndex...endIndex {
      result.append(DisplayItem(id: i, text: items[i]))
    }
     
    return result
  }
}
 
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    let formatter = NumberFormatter()
    formatter.numberStyle = .spellOut
    let data = (0..<10).map { formatter.string(from: NSNumber(value: $0))! }
     
    return ContentView(items: data)
  }
}

Replies

I had to initialize items to make it work…

  var items: [String] = ["Hello", "You", "Boys", "Girls"]


The problem is effectively that Top Text ([0]) moves to bottom before disappearing, which it should not.


I changed as follows, complemnting the test in removal:

removal: AnyTransition.opacity.combined(with: .move(edge: self.slideUp || (item.id == 0) ? .top : .bottom ))

It seems to work:


  var body: some View {
    HStack {
      VStack {
        ForEach(visibleItems(), id: \DisplayItem.id) { item in
            Text("\(item.text)").font(.system(size: 30)).animation(.easeInOut)
                .transition(.asymmetric(insertion: AnyTransition.opacity.combined(with: .move(edge: !self.slideUp ? .top : .bottom)),
                                        removal: AnyTransition.opacity.combined(with: .move(edge: self.slideUp || (item.id == 0) ? .top : .bottom ))))
        }
      }
      .animation(.easeInOut(duration: 1.0))
      .frame(width: 200, height: 200)

Reduced textSize to 30, for convenience.

Nope, sorry, that does not make a difference. Adding the || (item.id == 0) means that the first item will always slide up. This does not solve it for two reasons: 1) The first item is not always the top, depending on how far you went down the list; 2) It must not always go in the same direction.


You'rs probably looked like it worked because you only went down once, then up again. Have a look at my PreviewProvider. It generated more itmes so that I could go further down the list before going up again.


The problem remains: The .transition, with its parameters is added to the Text when it is inserted, or modified. It therefore has the state of the direction at that point. If is is removed because of a change of direction, then it will slide in the wrong direction.


I am wondering if there is a way to reset the animation and/or transition when the state changes or delaying it somehow so that the body is re-calculated when the direction changes, and then changed. Any ideas?

You're right !


So, I did this. It is just a partial solution, but may give the direction.


I created a state

  @State private var switched: Bool = false

To see if we switch direction of scroll


Then change the buttons to do nothing in such a case:


      VStack {
        Button(action: {
            self.switched = !self.slideUp
            self.slideUp = true
            if !self.switched {
                self.startIndex = min(self.items.count-1, self.startIndex + 1)
            }
        }, label: { Text("Slide Up") })
        Button(action: {
          self.switched = self.slideUp
          self.slideUp = false
            if !self.switched {
                self.startIndex = max(0, self.startIndex - 1)
            }
        }, label: { Text("Slide Down") })
      }


Problem is that button is inactive once, but …


Another way, hacky as well, is to sleep for 1s (less does not work), to leave the time for animation

      VStack {
        Button(action: {
            self.switched = !self.slideUp
            self.slideUp = true
            if self.switched {
                sleep(1)
            }
            self.startIndex = min(self.items.count-1, self.startIndex + 1)
        }, label: { Text("Slide Up") })
        Button(action: {
            self.switched = self.slideUp
            self.slideUp = false
            if self.switched {
                sleep(1)
            }
            self.startIndex = max(0, self.startIndex - 1)
        }, label: { Text("Slide Down") })
      }


Button is active, but there is no more animation.


My guess, for a full solution, would not to make slideUp a @state var. But just a published one.

Finally, with a dispatch, seems to work as intended.


If that works, thanks to close the thread, otherwise, please describe very precisely what behavior you would want to get.


struct ContentView: View {
  private struct DisplayItem {
    var id: Int
    var text: String
  }
   
  private let numVisibleItems = 3
   
  @State private var slideUp: Bool = true
  @State private var startIndex: Int = 0
 
//  var items: [String] = []
    var items: [String] = ["Hello", "You", "Boys", "Girls"]

  var body: some View {
    HStack {
      VStack {
        ForEach(visibleItems(), id: \DisplayItem.id) { item in
          Text("\(item.text)").font(.system(size: 30)).animation(.easeInOut)
            .transition(.asymmetric(
                insertion: AnyTransition.opacity.combined(with: .move(edge: !self.slideUp ? .top : .bottom )),
                removal: AnyTransition.opacity.combined(with: .move(edge: self.slideUp ? .top : .bottom ))))
        }
      }
      .animation(.easeInOut(duration: 1.0))
      .frame(width: 200, height: 200)
       
      VStack {
        Button(action: {
          self.slideUp = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.startIndex = min(self.items.count-1, self.startIndex + 1)
            }
        }, label: { Text("Slide Up") })
        Button(action: {
          self.slideUp = false
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.startIndex = max(0, self.startIndex - 1)
            }
        }, label: { Text("Slide Down") })
      }
    }
  }
   
  private func visibleItems() -> [DisplayItem] {
    let endIndex = min(startIndex + numVisibleItems - 1, items.count - 1)
    var result = [DisplayItem]()
    for i in startIndex...endIndex {
      result.append(DisplayItem(id: i, text: items[i]))
    }
     
    return result
  }
}
 
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    let formatter = NumberFormatter()
    formatter.numberStyle = .spellOut
    let data = (0..<10).map { formatter.string(from: NSNumber(value: $0))! }
     
    return ContentView(items: data)
  }
}

I initialized items in SceneDelegate


        let formatter = NumberFormatter()
        formatter.numberStyle = .spellOut
        let data = (0..<10).map { formatter.string(from: NSNumber(value: $0))! }
        let contentView = ContentView(items: data)


Works amazingly well now !

Thanks, this does solve the problem, but it is not as elegant as I have hoped. I am always concerned when I see arbitary timeouts in source code (the 0.1 delay in dispatch - you can change it from 100 ms to 1 ms and it still works). API changes, or even change in CPU load can change the code's behaviour.


But it does solve the problem, and also clearly illustrates what is going on, so I've marked it as correct. It would be nice though if there was a way to change the transision in a more deterministic way. I may keep on playing with this one for a while...

How about this one?


The main difference is that I do not have a timed async call. I am trying to more accuritly show what is happening, and trying to solve it.


They key issue is that the .transision on the text must be changed when the direction is changed before the text is removed. I did this by changing slideUp to currSlideDirection and adding another state, updatePending. If you press a button to slide up or down, it checks the current direction. If the current direction is not correct, then it only changes the current direction, and set the updatePending flag to indicate that a slide update is pending.


When the body is re-calculated (with the correct slide direction being aplied), the code checks if updatePending is set. If so, it clears updatePending, and slides in the current direction. It does this asynchronously (but as soon as it can, without timeout) simply because you cannot change state when the body is being re-calculated.


I've often run up against the issue of not being able to change state when the body of a view is being calculated, and had to solve it with more complex solutions. This patern of saving a state to indicate that an update is pending, and then doing it asynchronously may be a clean work around for many other similar problems.


struct ContentView: View {
  private struct DisplayItem {
    var id: Int
    var text: String
  }
  
  private enum SlideDirection {
    case up
    case down
  }
  
  private let numVisibleItems = 3
  
  @State private var currSlideDirection: SlideDirection = .down
  @State private var updatePending: Bool = false
  @State private var startIndex: Int = 0
  
  //  var items: [String] = []
  var items: [String] = ["Hello", "You", "Boys", "Girls"]
  
  var body: some View {
    if updatePending {
      // updatePending is set to true when the slide direction changed, but the
      // slide has not been implemented yet. Change the state (startIndex)
      // asynchronously so that the state is not changed in the body of the
      // view:
      DispatchQueue.main.async {
        self.updatePending = false
        self.currSlideDirection == .up ? self.slideUp() : self.slideDown()
      }
    }
    
    return HStack {
      VStack {
        ForEach(visibleItems(), id: \DisplayItem.id) { item in
          Text("\(item.text)").font(.system(size: 30)).animation(.easeInOut)
            .transition(.asymmetric(
              insertion: AnyTransition.opacity.combined(with: .move(edge: self.currSlideDirection == .down ? .top : .bottom )),
              removal: AnyTransition.opacity.combined(with: .move(edge: self.currSlideDirection == .up ? .top : .bottom ))))
        }
      }
      .animation(.easeInOut(duration: 1.0))
      .frame(width: 200, height: 200)
      
      VStack {
        Button(action: {
          if self.currSlideDirection == .down {
            // The current slide direction needs to change. This changes the
            // state, without sliding yet. The state change will change the
            // transistion on the text above but the slide will only happen
            // after this.
            self.updatePending = true
            self.currSlideDirection = .up
          } else {
            // Since we are already sliding in the correct direction, we do not
            // have to update the transition on the text above, so we can just
            // slide.
            self.slideUp()
          }
        }, label: { Text("Slide Up") })
        Button(action: {
          if self.currSlideDirection == .up {
            // See comment at slide up above.
            self.updatePending = true
            self.currSlideDirection = .down
          } else {
            // See comment at slide up above.
            self.slideDown()
          }
        }, label: { Text("Slide Down") })
      }
    }
  }
  
  func slideUp() {
    self.startIndex = min(self.items.count-1, self.startIndex + 1)
  }
  
  func slideDown() {
    self.startIndex = max(0, self.startIndex - 1)
  }
  
  private func visibleItems() -> [DisplayItem] {
    let endIndex = min(startIndex + numVisibleItems - 1, items.count - 1)
    var result = [DisplayItem]()
    for i in startIndex...endIndex {
      result.append(DisplayItem(id: i, text: items[i]))
    }
    
    return result
  }
}

Yes, that's a good idea to use another state and to set it in a dispatch. This avoids the delay.

Using dispatch to manage state var is an idea to keep.


Minor note: as items is loaded in SceneDelegate, no need of line 19.

Could replace with

var items: [String] = []
  //   var items: [String] = ["Hello", "You", "Boys", "Girls"]