Reputation: 192
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()
}
}
Upvotes: 6
Views: 2253
Reputation: 258345
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 pаrent
}
}
}
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)
Upvotes: 5
Reputation: 1519
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.
Upvotes: 1