graycampbell
graycampbell

Reputation: 7800

SwiftUI - Animate view onto screen from button tap anchored to button frame

I created an example project to show my view layout using colors. Here's what it looks like:

enter image description here

And here's the code to produce it:

struct ContentView: View {
    @State private var isShowingLeftPopup: Bool = false
    @State private var isShowingRightPopup: Bool = false

    var body: some View {
        TabView {
            VStack {
                Spacer()
                ZStack {
                    Color.red
                        .frame(height: 200)
                    HStack(spacing: 15) {
                        Color.accentColor
                            .disabled(self.isShowingRightPopup)
                            .onTapGesture {
                                self.isShowingLeftPopup.toggle()
                            }
                        Color.accentColor
                            .disabled(self.isShowingLeftPopup)
                            .onTapGesture {
                                self.isShowingRightPopup.toggle()
                            }
                    }
                    .frame(height: 70)
                    .padding(.horizontal)
                }
                Color.purple
                    .frame(height: 300)
            }
        }
    }
}

When either of the two blue rectangles are tapped, I want to animate a view onto the screen directly below the blue rectangles, filling the vertical space between the blue rectangles and the tab bar at the bottom. The animation isn't really that important at the moment - what I can't figure out is how to anchor a conditional view to the bottom of the blue rectangles and size it to fit the remaining space below.

I put together a mockup of what it should look like when the left blue rectangle is tapped:

enter image description here

I'm using fixed heights in this example, but I'm looking for a solution that doesn't rely on fixed values. Does anyone know how you would anchor the green rectangle to the bottom of the blue rectangles and dynamically size it to fill the vertical space all the way to the tab bar?

Upvotes: 1

Views: 1371

Answers (1)

kontiki
kontiki

Reputation: 40539

You can benefit from GeometryReader, Preferences and AnchorPreferences. I've written extensive articles about them. To learn more about how they work, please refer to them:

GeometryReader article: https://swiftui-lab.com/geometryreader-to-the-rescue/

Preferences article: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

Specifically, for what you would like to accomplish, you require to know the size and position of both blue views and the purple view (which indicates the lower limit of the green view). Once you get that information, the rest is easy. The following code will do just that:

import SwiftUI

struct MyData {
    let viewName: String
    let bounds: Anchor<CGRect>
}

struct MyPreferenceKey: PreferenceKey {
    static var defaultValue: [MyData] = []

    static func reduce(value: inout [MyData], nextValue: () -> [MyData]) {
        value.append(contentsOf: nextValue())
    }

    typealias Value = [MyData]
}

struct ContentView: View {
    @State private var isShowingLeftPopup: Bool = false
    @State private var isShowingRightPopup: Bool = false

    var body: some View {
        TabView {
            VStack {
                Spacer()
                ZStack {
                    Color.red
                        .frame(height: 200)
                    HStack(spacing: 15) {
                        Color.accentColor
                            .disabled(self.isShowingRightPopup)
                            .onTapGesture {
                                self.isShowingLeftPopup.toggle()
                            }
                            .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                                return [MyData(viewName: "leftView", bounds: $0)]
                            }

                        Color.accentColor
                            .disabled(self.isShowingLeftPopup)
                            .onTapGesture { self.isShowingRightPopup.toggle() }
                            .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                                return [MyData(viewName: "rightView", bounds: $0)]
                            }
                    }
                    .frame(height: 70)
                    .padding(.horizontal)
                }

                Color.purple
                    .frame(height: 300)
                    .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                        return [MyData(viewName: "purpleView", bounds: $0)]
                    }
            }.overlayPreferenceValue(MyPreferenceKey.self) { preferences in
                GeometryReader { proxy in
                    Group {
                        if self.isShowingLeftPopup {
                            ZStack(alignment: .topLeading) {
                                self.createRectangle(proxy, preferences)

                                HStack { Spacer() } // makes the ZStack to expand horizontally
                                VStack { Spacer() } // makes the ZStack to expand vertically
                            }.frame(alignment: .topLeading)
                        } else {
                            EmptyView()
                        }
                    }
                }
            }
        }
    }

    func createRectangle(_ geometry: GeometryProxy, _ preferences: [MyData]) -> some View {

        let l = preferences.first(where: { $0.viewName == "leftView" })
        let r = preferences.first(where: { $0.viewName == "rightView" })
        let p = preferences.first(where: { $0.viewName == "purpleView" })

        let bounds_l = l != nil ? geometry[l!.bounds] : .zero
        let bounds_r = r != nil ? geometry[r!.bounds] : .zero
        let bounds_p = p != nil ? geometry[p!.bounds] : .zero

        return RoundedRectangle(cornerRadius: 15)
            .fill(Color.green)
            .frame(width: bounds_r.maxX - bounds_l.minX, height: bounds_p.maxY - bounds_l.maxY)
            .fixedSize()
            .offset(x: bounds_l.minX, y: bounds_l.maxY)
    }
}

Upvotes: 4

Related Questions