skip to Main Content

Hoping someone may know of a solution for this animation issue as I can’t find a way to make it work!

Im using ForEach within LazyVStack within ScrollView. I have a .searchable modifier on the scrollview. When I enter/cancel the search field the navigation bar and search field animate upwards/downwards but my scrollview jumps without animation.

if I add .animation(.easeInOut) after .searchable it animates correctly. However there’s two issues, its deprecated in iOS 15.0, and it animates the list items in crazy ways as they appear and are filtered etc.

When using a List it also works but can’t be customised in the way I need. This issue is present in simulator, in previews and on device.

Does anyone know how I can get this to animate correctly without resorting to using List (Which doesn’t have the customisability I need for the list items)?

Thanks for your help!

A slimmed down version of what I’m doing to recreate the issue:

import SwiftUI

struct ContentView: View {
    @State var searchText: String = ""
    
    var body: some View {
        NavigationView {
            ScrollView(.vertical) {
                CustomListView()
            }
            .navigationTitle("Misbehaving ScrollView")
            .searchable(text: $searchText, placement: .automatic)
            // This .animation() will fix the issue but create many more...  
//            .animation(.easeInOut)
        }
    }
}

struct CustomListView: View {
    @State private var listItems = ["Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7", "Item 8", "Item 9", "Item 10"]
    
    var body: some View {
        LazyVStack(alignment: .leading, spacing: 10) {
            ForEach(listItems, id: .self) { item in
                CustomListItemView(item: item)
                    .padding(.horizontal)
            }
        }
    }
}

struct CustomListItemView: View {
    @State var item: String
    
    var body: some View {
        ZStack(alignment: .leading) {
            RoundedRectangle(cornerRadius: 20, style: .continuous)
                .foregroundColor(.green.opacity(0.1))
            VStack(alignment: .leading, spacing: 4) {
                Text(item)
                    .font(.headline)
                Text(item)
                    .font(.subheadline)
            }
            .padding(25)
        }
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

An even more basic example that displays the same issue:

import SwiftUI

struct SwiftUIView: View {
    @State var text = ""
    
    var body: some View {
        NavigationView {
            ScrollView {
                Text("1")
                Text("2")
                Text("3")
                Text("4")
                Text("5")
                Text("6")
            }
        }
        .searchable(text: $text)
    }
}

struct SwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SwiftUIView()
    }
}

2

Answers


  1. We need to animate ScrollView geometry changes synchronously with searchable text field appearance/disappearance, which as seen are animatable.

    There are two tasks here: 1) detect searchable state changes 2) animate ScrollView in correct place (to avoid unexpected content animations as already mentioned in question)

    A possible solution for task 1) is to read isSearching environment variable:

    .background(
        // read current searching state is available only in
        // child view level environment
        SearchingReaderView(searching: $isSearching)
    )
    
    // ...
    
    struct SearchingReaderView: View {
        @Binding var searching: Bool
        @Environment(.isSearching) private var isSearching
    
        var body: some View {
            Text(" ")
                .onChange(of: isSearching) {
                    searching = $0          // << report to perent
                }
        }
    }
    

    and for task 2) is to inject animation right during transition by modifying transaction:

    ScrollView(.vertical) {
        CustomListView()
    }
    .transaction {
        if isSearching || toggledSearch {
            // increased speed to avoid views overlaping
            $0.animation = .default.speed(1.2)
    
            // needed to animate end of searching
            toggledSearch.toggle()
        }
    }
    

    Tested with Xcode 13.4 / iOS 15.5 (debug slow animation for better visibility)

    demo

    Test code on GitHub

    Login or Signup to reply.
  2. Unfortunately this issue is present in UIKit as well. I’ve managed to find a solution here: Top Safe Area Constraint Animation

    The accepted answer can be applied by setting an instance of UIViewControllerRepresentable as background, but unfortunately it does not work on iOS 16.

    The other answer, about making the navigation bar opaque, works on iOS 16 as well. It can be applied either:

    • globally via the appearance proxy

       let appearance = UINavigationBarAppearance()
       appearance.configureWithOpaqueBackground()
       UINavigationBar.appearance().isTranslucent = false
       UINavigationBar.appearance().standardAppearance = appearance
       UINavigationBar.appearance().scrollEdgeAppearance = appearance
      
    • or locally via UIViewControllerRepresentable as background.

       class NavigationBarAppearanceViewController: UIViewController {
         override func viewDidLoad() {
           super.viewDidLoad()
      
           let appearance = UINavigationBarAppearance()
           appearance.configureWithOpaqueBackground()
           navigationController?.navigationBar.isTranslucent = false
           navigationController?.navigationBar.standardAppearance == appearance
           navigationController?.navigationBar.scrollEdgeAppearance = appearance
         }
       }
      
       struct NavigationBarAppearanceViewControllerRepresentable: UIViewControllerRepresentable {
         func makeUIViewController(context: Context) -> UIViewController { NavigationBarAppearanceViewController() }
         func updateUIViewController(_ viewController: UIViewController, context: Context) {}
       }
      
       extension View {
         func opaqueNavigationBar() -> some View {
           background(NavigationBarAppearanceViewControllerRepresentable())
         }
       }
      

    The appearance can be adjusted per design requirements.
    The only downside of this solution is loosing the beautiful translucent navigation bar where the content can be seen when scrolling behind, but still can be adjusted per screen basis.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search