Search code examples
swiftuiswiftui-animation

Animating showing/hiding of children a VStack in SwiftUI


** EDIT ** Suspect the UIKit view that this SwiftUI view is embedded in (via UIHostingController) is the problem, as @eXcore's solution works for me in preview. This is the UIKit layout code:

var searchBox: UIHostingController<WKMapDropdownView> = UIHostingController(rootView: WKMapDropdownView(activityTypes: []))


searchBox.view.backgroundColor = UIColor.clear
self.view.addSubview(searchBox.view)
    
searchBox.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
    searchBox.view.topAnchor.constraint(equalTo: self.mapView!.topAnchor, constant: 30),
    searchBox.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 20),
    searchBox.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -20),
]
NSLayoutConstraint.activate(constraints)

========

I have a SwiftUI View which contains a VStack with some children that are shown/hidden when the first child view is tapped.

The animation looks a bit weird as animating in causes the already-visible child to jump to the middle then expand back to the top. I want it to happen so the already-visible child remains in place and the hidden options slide down underneath.

I'm thinking a height animation for the children might be better, or maybe the VStack itself is the cause of the issue.

Can someone help please?

ViewModel:

import Foundation

enum WKMapDropdownState {
    case optionsCollapsed
    case optionsExpanded
    case dropdownCollapsed
}

enum WKMapSearchType {
    case postcode
    case name
    case activityType
}

class WKMapDropdownViewModel : ObservableObject {
    
    @Published var optionsExpanded : Bool = false
    
    @Published var titleVisible : Bool = true
    
    @Published var backArrowVisible : Bool = false
    
    private var currentState : WKMapDropdownState = .optionsCollapsed
    
    func toggleDropdown() {
        switch (currentState) {
            case .optionsCollapsed:
                expandOptions()
            case .optionsExpanded:
                collapseOptions()
            case .dropdownCollapsed:
    //                self.delegate?.dropdownBackTapped() -> Hide everything
                expandDropdown()
        }
    }
    
    func searchTypeButtonTapped(searchType: WKMapSearchType) {
        collapseDropdown()
    }
    
    private func showDropdownOptions() {
        optionsExpanded = true
    }
    
    private func hideDropdownOptions() {
        optionsExpanded = false
    }
    
    private func showCollapsedOpenviewButton() {
        titleVisible = true
        backArrowVisible = true
    }
    
    private func showExpandedOpenviewButton() {
        titleVisible = true
        backArrowVisible = false
    }
    
    private func expandOptions() {
        showDropdownOptions()
        currentState = .optionsExpanded
    }
    
    private func collapseOptions() {
        hideDropdownOptions()
        currentState = .optionsCollapsed
    }
    
    private func expandDropdown() {
        hideDropdownOptions()
        showExpandedOpenviewButton()
        currentState = .optionsCollapsed
    }
    
    private func collapseDropdown() {
        hideDropdownOptions()
        showCollapsedOpenviewButton()
        currentState = .dropdownCollapsed
    }
}

View:

struct WKMapDropdownView: View {
    @ObservedObject var viewModel = WKMapDropdownViewModel()
        var body: some View {
            VStack {
                HStack {
                    Button(action: {
                        withAnimation {
                            self.viewModel.toggleDropdown()
                        }
                    }) {
                        HStack {
                            Text("I want to search...")
                                .opacity(self.viewModel.titleVisible ? 1 : 0)
                                .foregroundColor(.white)
                                .font(.custom("Roboto-Regular", size: 19))
                                .frame(maxWidth: .infinity, alignment: .leading)
                            Image(self.viewModel.optionsExpanded ? "searchBoxUpArrow" : "searchBoxDownArrow")
                                .frame(maxWidth: .infinity, alignment: .trailing)
                                .animation(nil)
    
                                    
                        }
                        .frame(maxWidth: .infinity)
                        .padding(EdgeInsets(top: 19, leading: 22, bottom: 18, trailing: 25))
                    }
                        .background(Color.init("Tealish"))
                }
                .frame(maxHeight: .infinity, alignment: .top)
                
                
                if self.viewModel.optionsExpanded {
                    VStack {
                        Button(action: { self.viewModel.searchTypeButtonTapped(searchType: .postcode) }) {
                            Text("By Postcode")
                                .font(.custom("Roboto-Regular", size: 19))
                                .foregroundColor(Color.init("Tealish"))
                                .background(Color.clear)
                                .padding(EdgeInsets(top: 19, leading: 22, bottom: 18, trailing: 25))
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        .transition(.identity)
                            
                        Button(action: { self.viewModel.searchTypeButtonTapped(searchType: .name) }) {
                            Text("All Wiki Places")
                                .font(.custom("Roboto-Regular", size: 19))
                                .foregroundColor(Color.init("Tealish"))
                                .background(Color.clear)
                                .padding(EdgeInsets(top: 19, leading: 22, bottom: 18, trailing: 25))
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        .transition(.identity)
                            
                        Button(action: { self.viewModel.searchTypeButtonTapped(searchType: .activityType) }) {
                            Text("By Activity Type")
                                .font(.custom("Roboto-Regular", size: 19))
                                .foregroundColor(Color.init("Tealish"))
                                .background(Color.clear)
                                .padding(EdgeInsets(top: 19, leading: 22, bottom: 18, trailing: 25))
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        .transition(.identity)
                    }
                    .frame(maxHeight: .infinity, alignment: .top)
                }
            }
            .frame(maxHeight: .infinity, alignment: .top)
            .background(Color(UIColor.systemBackground))
            .clipShape(RoundedRectangle(cornerRadius:10))
        }
    }
    struct WKMapDropdownView_Previews: PreviewProvider {
        static var previews: some View {
            WKMapDropdownView()
        }
}

Current behaviour which I don't want


Solution

  • I can't comment so I will assume I'm solving correct problem.

    First of all, I don't quite understand why do you have maxHeight: .infinity in your VStacks, removing them is probably part of the answer, after that I have this:

    Option 1

    Next up, if you want to pin this dropdown to the top, I think the easiest option would be to place Spacer() under your dropdown view, like this:

    Option 2

    So here is the overview of fixes:

    struct WKMapDropdownView: View {
        @ObservedObject var viewModel = WKMapDropdownViewModel()
        var body: some View {
            VStack { // Wrapped into another VStack
                VStack {
                    HStack {
                       ...
                    }
                    .frame(alignment: .top) // Removed maxHeight
                    
                    
                    if self.viewModel.optionsExpanded {
                        VStack {
                            ...
                        }
                        .frame(alignment: .top) // Removed maxHeight
                    }
                }
                .frame(alignment: .top) // Removed maxHeight
                .background(Color(UIColor.systemBackground))
                .clipShape(RoundedRectangle(cornerRadius:10))
                Spacer() // Added Spacer
            }
        }
    }