iosswiftuiios-animations

SwiftUI animate rectangle fill from bottom to up


I have a Rectangle() SwiftUI view. I am trying to animate the fill inside it based on a numeric value. For example the height of the rectangle would be totalHeight * ratio. Here is the code:

struct SquareOptionView: View {
    
    let title: String
    @Binding var voteRatio: Double
    let color: Color
    let tapAction: ()->()
    
    var body: some View {
        VStack(spacing: 10.0){
            Text(title)
            Text(
                "\(voteRatio * 100, specifier: "%.2f")%"
            )
        }
        .padding(25.0)
        .frame(maxWidth: .infinity, alignment: .bottom)
        .background{
            color
                .containerRelativeFrame(.vertical, alignment: .bottom) { length, _ in
                    return length * voteRatio
                }
                .animation(.bouncy, value: voteRatio)
                .frame(alignment: .bottom)
        }
        .onTapGesture {
            tapAction()
        }
    }
}

It works well but the animation ends up going to the center rather than top to bottom. As you can see I tried spamming the bottom alignment but the views still animate to the center of their size.

Code snippets:

import Foundation

class SquareOptionsContainerViewModel: ObservableObject{
    
    let firstOptionTitle: String
    let secondOptionTitle: String
    @Published var firstVoteRatio: Double
    @Published var secondVoteRatio: Double
    let firstOptionClickListener: ()->()
    let secondOptionClickListener: ()->()
    
    init(firstOptionTitle: String, secondOptionTitle: String, firstVoteRatio: Double, secondVoteRatio: Double, firstOptionClickListener: @escaping () -> Void, secondOptionClickListener: @escaping () -> Void) {
        self.firstOptionTitle = firstOptionTitle
        self.secondOptionTitle = secondOptionTitle
        self.firstVoteRatio = firstVoteRatio
        self.secondVoteRatio = secondVoteRatio
        self.firstOptionClickListener = firstOptionClickListener
        self.secondOptionClickListener = secondOptionClickListener
    }
}

ParentView:

import SwiftUI

struct ContentView: View {
    
    @ObservedObject var viewModel: SquareOptionsContainerViewModel

    var body: some View {
        HStack(spacing: 0.0){
            SquareOptionView(title: viewModel.firstOptionTitle, voteRatio: $viewModel.firstVoteRatio, color: .green){
                viewModel.firstVoteRatio = random(min: 0.0, max: 1.0)
            }
            Rectangle()
                .frame(width: 2.0)
                .foregroundColor(
                    .black
                )
            SquareOptionView(title: viewModel.secondOptionTitle, voteRatio: $viewModel.secondVoteRatio, color: .red){
                viewModel.secondVoteRatio = random(min: 0.0, max: 1.0)
            }
        }
        .clipShape(
            RoundedRectangle(cornerSize: .init(width: 10.0, height: 10.0))
        )
        .overlay(
            RoundedRectangle(cornerSize: .init(width: 10.0, height: 10.0))
                .stroke(lineWidth: 2.0)
        )
        .padding(.vertical, 10.0)
    }
    
    func random(min: Double, max: Double) -> Double {
        return random * (max - min) + min
    }
    
    var random: Double {
        return Double(arc4random()) / 0xFFFFFFFF
    }
}

Sample Repo: https://github.com/mrikh/test

enter image description here


Solution

  • I think you misunderstood what "container" means in containerRelativeFrame. Check out the documentation to see what a "container" means in this context.

    If I understand correctly, you don't want the frame to be relative to a "container", but just relative to the parent view.

    To set a frame that fills all the available space in the parent view, you should use .frame(maxWidth: .infinity, maxHeight: .infinity). In fact, I think if you replace the .frame modifier just above the .background modifier in swiftPunk's answer, it would work as expected:

    .frame(maxWidth: .infinity, maxHeight: .infinity) // <------
    .background{
        GeometryReader { proxy in
            Color.white.opacity(0.01)
            color
                .offset(x: .zero, y: proxy.size.height*(1 - voteRatio))
            
        }
    }
    

    Note that this makes the whole VStack's height match its parent, not just the view in the background.


    I personally would use a scaleEffect that scales the background color vertically. Use anchor: .bottom so that it scales from the bottom.

    struct SquareOptionView: View {
        
        let title: String
        // voteRatio doesn't need to be a Binding because you never change it in SqaureOptionView
        let voteRatio: Double
        let color: Color
        let tapAction: ()->()
        
        var body: some View {
            VStack(spacing: 10.0){
                Text(title)
                Text(
                    "\(voteRatio * 100, specifier: "%.2f")%"
                )
            }
            .padding(25.0)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .contentShape(.rect)
            .background {
                color
                    .scaleEffect(x: 1, y: voteRatio, anchor: .bottom)
                    .animation(.bouncy, value: voteRatio)
            }
            .onTapGesture {
                tapAction()
            }
        }
    }