1

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

12
  • Can you show a more complete example? A minimal reproducible example? Commented Oct 11, 2024 at 21:49
  • @Sweeper Added a bit of code to get a minimal example going in a sample :) Passing in any random values to the view model when constructing it should work as well! Commented Oct 11, 2024 at 22:13
  • Added a sample repo! Commented Oct 12, 2024 at 6:53
  • You are using ObservedObject incorrectly. If ContentView is the owner of SquareOptionsContainerViewModel, it should be a @StateObject and initialised directly in ContentView. If ContentView is not the owner, there should be a @StateObject somewhere else, and that @StateObject should be passed to ContentView. That said, I find this view model unnecessary in the first place. I'd suggest putting everything in ContentView as @State. Commented Oct 12, 2024 at 7:00
  • You are 100% right. Made the changes. But the problem still remains sadly Commented Oct 12, 2024 at 7:09

3 Answers 3

1

You can get your original code working with two small changes to SquareOptionView:

  1. Increase the height of the VStack to the maximum available by adding maxHeight: .infinity to the frame you are already setting. You can also remove the alignment: .bottom, unless you want the value to be shown at the bottom instead of in the middle.

  2. Set the background using alignment: .bottom.

Other suggestions:

  • You might want to add .contentShape(Rectangle()) before the tap gesture, so that taps work in the blank areas too.

  • The frame setting alignment: .bottom is redundant, this can be removed.

Here is the updated example:

// SquareOptionView VStack(spacing: 10.0) { // ... } .padding(25.0) .frame(maxWidth: .infinity, maxHeight: .infinity) // 👈 maxHeight added, alignment removed .background(alignment: .bottom) { // 👈 alignment added color // other modifiers as before, except: // .frame(alignment: .bottom) // 👈 not needed } .contentShape(Rectangle()) // 👈 added .onTapGesture { // ... } 

With these changes, it will appear to work. However, if you look closely, the filled area does not accurately correspond to the percentage value. This is because, containerRelativeFrame is using the full screen height, instead of the height of the VStack.

To fix this, a GeometryReader can be used to measure the size of the container instead. A GeometryReader is greedy and uses all the space available, so alignment: .bottom needs to be moved from the .background modifier to a second frame that you set on the color, after setting its height:

.background { GeometryReader { proxy in color .frame(height: proxy.size.height * voteRatio) .frame(maxHeight: .infinity, alignment: .bottom) .animation(.bouncy, value: voteRatio) } } 

Also, if you don't want the full height of the screen to be used, set a maximum height on the parent HStack in ContentView:

HStack(spacing: 0.0) { // ... } // + other modifiers .frame(maxHeight: 300) // 👈 added 

Animation

Sign up to request clarification or add additional context in comments.

4 Comments

Which website did you used for making gif? I cannot uploade any gif corrently it is always bigger than the size should be.
@swiftPunk I used the built-in screen-capture on a Mac (Shift-Cmd-5), then converted it to a gif using a free web service (Ezgif, just search for "mp4 to gif" for others). SO lets you upload up to 2 MB. You can keep it within this limit by using a smaller final size / less frames-per-sec and/or optimising the converted gif (Ezgif gives you all of these options).
Thanks can you tell me your setting in ezgif? what you set for Size, Frame rate (FPS) and Method? I tried for test, but it is getting always big in size, unless I set very low resolution.
@swiftPunk I usually set the frame rate to the maximum possible for the length of video, then pick a size that is slightly smaller than the actual size on screen. I leave the method set to the default. If the converted gif is still > 2MB then I try to reduce the file size using the optimisation feature.
1

Here is a way using offset:

import SwiftUI struct ContentView: View { @State private var voteRatio1: Double = .zero @State private var voteRatio2: Double = .zero var body: some View { HStack(spacing: .zero) { SquareOptionView(title: "Hello", voteRatio: $voteRatio1, color: .red, tapAction: { if voteRatio1 == .zero { voteRatio1 = 1.0 } else { voteRatio1 = .zero } }) SquareOptionView(title: "world!", voteRatio: $voteRatio2, color: .green, tapAction: { if voteRatio2 == .zero { voteRatio2 = 1.0 } else { voteRatio2 = .zero } }) } .clipShape( RoundedRectangle(cornerRadius: 6) ) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.black, lineWidth: 2) ) .overlay( Color.black .frame(width: 2) ) .padding() } } 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{ GeometryReader { proxy in Color.white.opacity(0.01) color .offset(x: .zero, y: proxy.size.height*(1 - voteRatio)) } } .animation(Animation.interactiveSpring, value: voteRatio) .clipped() .onTapGesture { tapAction() } } } 

enter image description here

7 Comments

Thanks! That is definitely a step in the direction I want but it is not quite there yet. I'll try to play around with the values and see if i can get the behaviour i want. For some reason the height doesn't go all the way to the top. The color fills 90% of the way correctly and then slides up to center itself in the view!
@Rikh: I think you mixed up codes in parent view badly, I made a simple working use case for SquareOptionView, I recomannd you start fresh with this bace code that I am giving you, and build on this. look for update.
What makes you say that? In the parent, logically and visually (apart from the animation) it is working as expected :O
@Rikh: If you think your codes logically and visually working as expected, why would you have opened this question? or why you got the animation issue?, maybe they are working but with a not in front of what you discribed. ps: "What makes you say that?" I have a magic globe, I looked to the globe.
Eesh no need to be rude. I am asking for input on what makes you think it is wrong? What can be improved. Which part can change? Just FYI I tried your code on a sample and it is really not working well. Here is the sample repo for you to play with: github.com/mrikh/test Maybe it works nicely when values are between 1-0 but... that was never a requirement.
|
0

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() } } } 

1 Comment

Interesting. I thought the container to the background modifier would be the parent which in this case is the VStack. I am an idiot for not thinking about the maxHeight. Thanks for mentioning the scaleEffect! Never used it before :)

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.