SwiftUI technique to update UINavigationBarAppearance.backgroundImage dynamically? - swiftui

I am implementing a gradient background for the top navigation bar in SwiftUI using a standard NavigationView. For the gradient I am using a UIGraphicsImageRenderer to create a gradient image which I then assign to the backgroundImage of UINavigationBarAppearance. So far everything is behaving correctly in terms of drawing the gradient and having it render on the top bar.
However, I have not yet been able to make the backgroundImage change dynamically per view. Full code below that should work if you paste it into a playground.
import SwiftUI
struct NavBarGradient: ViewModifier {
init(from: UIColor, to: UIColor) {
let appearance = UINavigationBarAppearance()
appearance.backgroundColor = .clear
let imageRenderer = UIGraphicsImageRenderer(size: .init(width: 1, height: 1))
let gradientImage : UIImage = imageRenderer.image { ctx in
let gradientLayer = CAGradientLayer()
gradientLayer.frame = imageRenderer.format.bounds
gradientLayer.colors = [from.cgColor, to.cgColor]
gradientLayer.locations = [0, 1]
gradientLayer.startPoint = .init(x: 0.0, y: 0.0)
gradientLayer.endPoint = .init(x: 0.5, y: 1.0)
gradientLayer.render(in: ctx.cgContext)
}
appearance.backgroundImage = gradientImage
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
}
func body(content: Content) -> some View {
content
}
}
extension View {
func navBarGradient(from: UIColor = .systemRed, to: UIColor = .systemYellow) -> some View {
modifier(NavBarGradient(from: from, to: to))
}
}
struct SimpleView: View {
var body: some View {
Text("Gradient View").navigationTitle("Gradient Colors")
}
}
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(
"Blue to Cyan",
destination: SimpleView()
.navBarGradient(from: .systemBlue, to: .systemCyan)
)
NavigationLink(
"Green to Mint",
destination: SimpleView()
.navBarGradient(from: .systemGreen, to: .systemMint)
)
NavigationLink(
"Red to Yellow",
destination: SimpleView()
.navBarGradient() // comment me out and previous modifier wins
)
}
.navigationTitle("Main Menu")
.navigationBarTitleDisplayMode(.inline)
}
}
}
Right now the .navBarGradient() view modifier seems to be "last one wins" which makes sense given how the view system works in SwiftUI. So what I am trying to understand is - what is the "SwiftUI way" of updating the appearance dynamically?
I have been able to achieve a gradient background in the top bar by adapting solutions like this one; it works exactly as desired. But it seems that it would be a useful thing to be able to update the top bar background image dynamically for things like server-side images.
Thanks in advance!

Related

SwiftUI: view states updated in Xcode preview but not in the built app

Edited:
Sorry for the original long story, following is a short minimal reproducible standalone example I can think of:
import SwiftUI
extension View {
/// get view's size and do something with it.
func getSize(action: #escaping (CGSize) -> Void) -> some View {
overlay(GeometryReader{ geo in
emptyView(size: geo.size, action: action)
})
}
// private empty view
private func emptyView(
size : CGSize,
action: #escaping (CGSize) -> Void
) -> some View {
action(size) // ⭐️ side effect❗️
return Color.clear
}
}
struct MyView: View {
#State private var size = CGSize(width: 300, height: 200)
#State private var ratio: CGFloat = 1
var body: some View {
VStack {
Spacer()
cell
Spacer()
controls
}
}
var cell: some View {
Color.pink
.overlay {
VStack {
Text("(\(Int(size.width)), \(Int(size.height)))")
Text("aspect ratio: \(String(format: "%.02f", ratio))")
}
}
.getSize { size in
print(size)
// although it works fine in Xcode preview,
// it seems this line never runs in the built app.
// (aspect ratio is updated in preview, but not in the built app)
ratio = size.width / size.height
// not even a single line in the console when run in the app.
print(ratio)
}
.frame(width: size.width, height: size.height)
}
var controls: some View {
VStack {
Slider(value: $size.width, in: 50...300, step: 1)
Slider(value: $size.height, in: 50...300, step: 1)
}
.padding(40)
}
}
Now the code above behaves differently in the Xcoe preview and the built app:
My question is why the built app is not updating the "ratio" part in the UI?
original long story below:
I was doing some custom layout for an array of items, and used GeometryReader to read the proposed size from parent and then tried to update some view states based on that size.
It worked perfectly fine in the Xcode preview, but failed to update (some) view states in the built app, as you can see in the following GIF:
The following code is used in the preview:
struct ItemsView_Previews: PreviewProvider {
static var previews: some View {
ItemsView()
.preferredColorScheme(.dark)
}
}
and the following is for the app's content view:
struct ContentView: View {
var body: some View {
ItemsView()
.overlay {
Text("Built App")
.font(.largeTitle)
.bold()
.foregroundColor(.orange)
.opacity(0.3)
.shadow(radius: 2)
}
}
}
as you can see, they both use exactly the same ItemsView, which is defined by the following code:
import SwiftUI
struct ItemsView: View {
#State private var size = CGSize(300, 300) // proposed size
#State private var rows = 0 // current # of rows
#State private var cols = 0 // current # of cols
#State private var ratio: CGFloat = 1 // current cell aspect ratio
#State private var idealRatio: CGFloat = 1 // ideal cell aspect ratio
let items = Array(1...20)
var body: some View {
VStack {
ScrollView {
itemsView // view for layed out items
}
controls // control size, cell ratio
}
.padding()
}
}
extension ItemsView {
/// a view with layed out item views
var itemsView: some View {
// layout item views
items.itemsView { size in // Array+ .itemsView()
// ⭐ inject layout instance
RatioRetainingLayout( // RatioRetainingLayout
idealRatio,
count: items.count,
in: size
)
} itemView: { i in
// ⭐ inject view builder
Color(hue: 0.8, saturation: 0.8, brightness: 0.5)
.padding(1)
.overlay {
Text("\(i)").shadow(radius: 2)
}
}
// ⭐ get proposed size from parent
.getSize { proposedSize in // 🌀View+ .getSize()
// ⭐ recompute layout
let layout = RatioRetainingLayout( // 👔 RatioRetainingLayout
idealRatio,
count: items.count,
in: proposedSize
)
// ⭐ update view states
rows = layout.rows
cols = layout.cols
ratio = layout.cellSize.aspectRatio // 🅿️ Vector2D: CGSize+ .aspectRatio
}
// ⭐ proposed size
.frame(size) // 🌀View+ .frame(size), .dimension()
.dimension(.topLeading, arrow: .blue, label: .orange)
.padding(4)
.shadowedBorder() // 🌀View+ .shadowedBorder()
.padding(40)
}
/// sliders to control proposed size, ideal ratio
var controls: some View {
SizeRatioControl( // 👔 SizeRatioControl
size: $size,
rows: $rows,
cols: $cols,
idealRatio: $idealRatio,
ratio: $ratio
)
}
}
I used some custom extensions, protocols and types to support the ItemsView struct, but I think they are not relevant, if you're interested, you can have a look at GitHub.
I think the most relevant part in the above code is the following, where it tries to update some view states with respect to the proposed size:
// ⭐ get proposed size from parent
.getSize { proposedSize in // 🌀View+ .getSize()
// ⭐ recompute layout
let layout = RatioRetainingLayout( // 👔 RatioRetainingLayout
idealRatio,
count: items.count,
in: proposedSize
)
// ⭐ update view states
rows = layout.rows
cols = layout.cols
ratio = layout.cellSize.aspectRatio // 🅿️ Vector2D: CGSize+ .aspectRatio
}
and the .getSize() part is a custom View extension which I used to get the proposed size from parent by using GeometryReader:
extension View {
/// get view's size and do something with it.
func getSize(action: #escaping (CGSize) -> Void) -> some View {
background(GeometryReader{ geo in
emptyView(size: geo.size, action: action)
})
}
// private empty view
private func emptyView(
size : CGSize,
action: #escaping (CGSize) -> Void
) -> some View {
action(size) // ⭐️ side effect❗️
return EmptyView()
}
}
While everything works fine in the Xcode preview, sadly it just doesn't work in the built app.
Am I doing something wrong with the SwiftUI view states? Please help. Thanks.
I finally come to realize that I've been keeping violating the most important rule in SwiftUI - the "source of truth" rule.
I shouldn't have made the ratio a #State private var in the first place, its value totally depends on size, and that means ratio should be a computed property instead.
So, with the following revision, everything works just fine:
(we don't even need the orginal .getSize() extension)
struct MyView: View {
// ⭐️ source of truth
#State private var size = CGSize(width: 300, height: 200)
// ⭐️ computed property
var ratio: CGFloat { size.width / size.height }
var body: some View {
VStack {
Spacer()
cell
Spacer()
controls
}
}
var cell: some View {
Color.pink
.overlay {
VStack {
Text("(\(Int(size.width)), \(Int(size.height)))")
Text("aspect ratio: \(String(format: "%.02f", ratio))")
}
}
.frame(width: size.width, height: size.height)
}
var controls: some View {
VStack {
Slider(value: $size.width, in: 50...300, step: 1)
Slider(value: $size.height, in: 50...300, step: 1)
}
.padding(40)
}
}

SwiftUI animation not working using animation(_:value:)

In SwiftUI, I've managed to make a Button animate right when the view is first drawn to the screen, using the animation(_:) modifier, that was deprecated in macOS 12.
I've tried to replace this with the new animation(_:value:) modifier, but this time nothing happens:
So this is not working:
struct ContentView: View {
#State var isOn = false
var body: some View {
Button("Press me") {
isOn.toggle()
}
.animation(.easeIn, value: isOn)
.frame(width: 300, height: 400)
}
}
But then this is working. Why?
struct ContentView: View {
var body: some View {
Button("Press me") {
}
.animation(.easeIn)
.frame(width: 300, height: 400)
}
}
The second example animates the button just as the view displays, while the first one does nothing
The difference between animation(_:) and animation(_:value:) is straightforward. The former is implicit, and the latter explicit. The implicit nature of animation(_:) meant that anytime ANYTHING changed, it would react. The other issue it had was trying to guess what you wanted to animate. As a result, this could be erratic and unexpected. There were some other issues, so Apple has simply deprecated it.
animation(_:value:) is an explicit animation. It will only trigger when the value you give it changes. This means you can't just stick it on a view and expect the view to animate when it appears. You need to change the value in an .onAppear() or use some value that naturally changes when a view appears to trigger the animation. You also need to have some modifier specifically react to the changed value.
struct ContentView: View {
#State var isOn = false
//The better route is to have a separate variable to control the animations
// This prevents unpleasant side-effects.
#State private var animate = false
var body: some View {
VStack {
Text("I don't change.")
.padding()
Button("Press me, I do change") {
isOn.toggle()
animate = false
// Because .opacity is animated, we need to switch it
// back so the button shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
animate = true
}
}
// In this case I chose to animate .opacity
.opacity(animate ? 1 : 0)
.animation(.easeIn, value: animate)
.frame(width: 300, height: 400)
// If you want the button to animate when the view appears, you need to change the value
.onAppear { animate = true }
}
}
}
Follow up question: animating based on a property of an object is working on the view itself, but when I'm passing that view its data through a ForEach in the parent view, an animation modifier on that object in the parent view is not working. It won't even compile. The objects happen to be NSManagedObjects but I'm wondering if that's not the issue, it's that the modifier works directly on the child view but not on the passed version in the parent view. Any insight would be greatly appreciated
// child view
struct TileView: View {
#ObservedObject var tile: Tile
var body: some View {
Rectangle()
.fill(tile.fillColor)
.cornerRadius(7)
.overlay(
Text(tile.word)
.bold()
.font(.title3)
.foregroundColor(tile.fillColor == .myWhite ? .darkBlue : .myWhite)
)
// .animation(.easeInOut(duration: 0.75), value: tile.arrayPos)
// this modifier worked here
}
}
struct GridView: View {
#ObservedObject var game: Game
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 4)
var body: some View {
GeometryReader { geo in
LazyVGrid(columns: columns) {
ForEach(game.tilesArray, id: \.self) { tile in
Button(action: {
tile.toggleSelectedStatus()
moveTiles() <- this changes their array position (arrayPos), and
the change in position should be animated
}) {
TileView(tile: tile)
.frame(height: geo.size.height * 0.23)
}
.disabled(tile.status == .solved || tile.status == .locked)
.animation(.easeInOut(duration: 0.75), value: arrayPos)
.zIndex(tile.status == .locked ? 1 : 0)
}
}
}
}
}

How can I prevent SwiftUI to Re-Initializing Initialized-View in a ForEach loop?

I have a button which add a new object of Type CircleInfo to an array called circleArray! this array will be used to supply data to work for a ForEach loop, All is working except one big issue that I noticed!
The Issue: When I add new object to array, the action of updating array make ForEach to update itself as well, and it brings app and Memory to knee with a little more detailed View than a simple Circle, because it starts Re-Initializing Initialized-View again! For example we got 10 Circle in Screen, as soon as I add new Circle, Foreach will go render all rendered 10 circle plus the new one!
Maybe you say it is the way of working SwiftUI which draw screen with small changes, But I am telling with more and more data it goes eat so much memory and CPU!
My Goal: I want to find a new way of adding new object to screen, more likely adding overly or something like that which those not need ForEach! because as soon as we change items of array or range of that array, it will go draw all again!
struct CircleInfo: Identifiable {
let id: UUID = UUID()
let circleColor: Color = Color(red: Double.random(in: 0...1), green: Double.random(in: 0...1), blue: Double.random(in: 0...1))
}
struct CircleView: View {
let circleInfo: CircleInfo
let index: Int
init(circleInfo: CircleInfo, index: Int) {
self.circleInfo = circleInfo
self.index = index
print("initializing circle:", index)
}
var body: some View {
Circle()
.fill(circleInfo.circleColor)
.frame(width: 200, height: 200, alignment: .center)
}
}
struct ContentView: View {
#State var circleArray: [CircleInfo] = [CircleInfo]()
var body: some View {
GeometryReader { geometry in
Color
.yellow
.ignoresSafeArea()
ForEach(Array(circleArray.enumerated()), id:\.element.id) { (index, item) in
CircleView(circleInfo: item, index: index)
.position(x: geometry.size.width/2, y: 20*CGFloat(index) + 100)
}
}
.overlay(button, alignment: Alignment.bottom)
}
var button: some View {
Button("add Circle") { circleArray.append(CircleInfo()) }
}
}
Easiest way to achieve your goal is to have an array of CircleView instead of CircleInfo.
First make CircleView identifiable:
struct CircleView: View, Identifiable {
let id: UUID = UUID()
…
Then use a CircleView array in ForEach
struct ContentView: View {
#State var circleArray: [CircleView] = [CircleView]()
var body: some View {
GeometryReader { geometry in
Color
.yellow
.ignoresSafeArea()
ForEach(circleArray) { item in
item
.position(x: geometry.size.width/2, y: 20*CGFloat(item.index) + 100)
}
}
.overlay(button, alignment: Alignment.bottom)
}
var button: some View {
Button("add Circle") { circleArray.append(CircleView(circleInfo: CircleInfo(), index: circleArray.count)) }
}
}
You'll definitely use the CPU less.

SwiftUI - animating a new image inside the current view

I have a View where I use a Picture(image) subview to display an image, which can come in different height and width formats.
The reference to the image is extracted from an array, which allows me to display different images in my View, by varying the reference. SwiftUI rearrange the content of view for each new image
I would like an animation on this image, say a scale effect, when the image is displayed
1) I need a first .animation(nil) to avoid animating the former image (otherwise I have an ugly fade out and aspect ratio deformation). Seems the good fix
2) But then I have a problem with the scaleEffect modifier (even if I put it to scale = 1, where it should do nothing)
The animation moves from image 1 to image 2 by imposing that the top left corner of image 2 starts from the position of top left corner of image 1, which, with different widths and heights, provokes a unwanted translation of the image center
This is reproduced in the code below where for demo purposes I'm using system images (which are not prone to bug 1))
How can I avoid that ?
3) In the demo code below, I trigger the new image with a button, which allows me to use an action and handle "scale" modification and achieve explicitly the desired effect. However in my real code, the image modification is triggered by another change in another view.
Swift knows that, hence I can use an implicit .animation modifier.
However, I can't figure out how to impose a reset of "scale" for any new image and perform my desired effect.
If I use onAppear(my code), it only works for the first image displayed, and not the following ones.
In the real code, I have a Picture(image) view, and Picture(image.animation()) does not compile.
Any idea how to achieve the action in the below code in the Button on an implicit animation ?
Thanks
import SwiftUI
let portrait = Image(systemName: "square.fill")
let landscape = Image(systemName: "square.fill")
struct ContentView: View {
#State var modified = false
#State var scale: CGFloat = 1
var body: some View {
return VStack(alignment: .center) {
Pictureclip(bool: $modified)
.animation(nil)
.scaleEffect(scale)
.animation(.easeInOut(duration: 1))
Button(action: {
self.modified.toggle()
self.scale = 1.1
DispatchQueue.main.asyncAfter(deadline: .now() + 1)
{self.scale = 1}
}) {
Text("Tap here")
.animation(.linear)
}
}
}
}
struct Pictureclip: View {
#Binding var bool: Bool
var body: some View {
if bool == true {
return portrait
.resizable()
.frame(width: 100, height: 150)
.foregroundColor(.green)
} else {
return landscape
.resizable()
.frame(width: 150, height: 100)
.foregroundColor(.red)
}
}
}
I have a semi answer to my question, namely points 1 & 2 (here with reference to two jpeg images in the asset catalog)
import SwiftUI
let portrait = Image("head")
let landscape = Image("sea")
struct ContentView: View {
#State var modified = false
#State var scale: CGFloat = 0.95
var body: some View {
VStack(){
GeometryReader { geo in
VStack {
Picture(bool: self.modified)
.frame(width: geo.size.width * self.scale)
}
}
Spacer()
Button(action: {
self.scale = 0.95
self.modified.toggle()
withAnimation(.easeInOut(duration: 0.5)){
self.scale = 1
}
}) {
Text("Tap here")
}
}
}
}
struct Picture: View {
var bool: Bool
var body: some View {
if bool == true {
return portrait
.resizable().aspectRatio(contentMode: .fit)
.padding(.all,6.0)
} else {
return landscape
.resizable().aspectRatio(contentMode: .fit)
.padding(.all,6.0)
}
}
}
This solution enables scaling without distorting the aspect ratio of the new image during the animation. But It does not work in a code where the image update is triggered in another view. I guess I have to restructure my code, either to solve my problem or to expose it more clearly.
Edit: a quick and dirty solution is to put the triggering code (here the action code in the button) in the other view. Namely, put in view B the code that animates view A, with a state variable passed to it (here, "scale"). I'm sure there are cleaner ways, but at least this works.
I am not sure about it, but maybe it can be helpful for you.
Use DataBinding structure. I use it like this:
let binding = Binding<String>(get: {
self.storage
}, set: { newValue in
self.textOfPrimeNumber = ""
self.storage = newValue
let _ = primeFactorization(n: Int(self.storage)!, k: 2, changeable: &self.textOfPrimeNumber)
})

How can I split VStack to two sub view with double height of second sub view than first sub view?

I just started with SwiftUI, and seems VStack and HStack is very similar as flex box in web. On the web, it's easy to split two sub views as height weight with flex
<div id="parent" style="display: flex; flex-direction: column; height: 300px">
<div id="subA" style="flex: 1; background-color: red">Subview A</div>
<div id="subB" style="flex: 2; background-color: yellow">Subview B</div>
</div>
I wonder if it's possible on swiftUI too.
VStack {
VStack {
Text("Subview A")
} // Subview A with height 100
.background(Color.red)
VStack {
Text("Subview B")
} // Subview B with height 200
.background(Color.yellow)
}
.frame(height: 300, alignment: .center)
How can I implement that?
UPDATE #2:
Thanks to this answer and code from #kontiki, here's what easily works instead of using this deprecated method:
Declare this:
#State private var rect: CGRect = CGRect()
Then create this:
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> ShapeView<Rectangle, Color> in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return Rectangle().fill(Color.clear)
}
}
}
}
(For those familiar with UIKit, you are basically creating an invisible CALayer or UIView in the parent and passing it's frame to the subview - apologies for not being 100% technically accurate, but remember, this is not a UIKit stack in any way.)
Now that you have the parent frame, you can use it as a base for a percentage - or "relative" - of it. In this question there's a nested VStack inside another and you want the lower Text to be twice the vertical size of the top one. In the case of this answer, adjust your `ContentView to this:
struct ContentView : View {
#State private var rect: CGRect = CGRect()
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red)
.frame(height: rect.height * 0.25)
YellowView()
}
.background(GeometryGetter(rect: $rect))
}
}
UPDATE #1:
As of beta 4, this method is deprecated. relativeHeight, relativeWidth, relativeSizehave all been deprecated. Useframeinstead. If you want *relatve* sizing based on aView's parent, use GeometryReader` instead. (See this question.)
ORIGINAL POST:
Here's what you want. Keep in mind that without modifiers, everything is centered. Also, relativeHeight seems (at least to some) not very intuitive. The key is to remember that in a VSTack the parent is 50% of the screen, so 50% of 50% is actually 25%.
Alternatively, you can dictate frame heights (letting the width take up the whole screen). but your example suggests you want the red view to be 25% of the entire screen no matter what the actual screen size is.
struct RedView: View {
var body: some View {
ZStack {
Color.red
Text("Subview A")
}
}
}
struct YellowView: View {
var body: some View {
ZStack {
Color.yellow
Text("Subview B")
}
}
}
struct ContentView : View {
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red).relativeHeight(0.50)
YellowView()
}
}
}