Image keeps its aspect ratio but its frame doesn't - swiftui

In the following code, I did some experiments and please note that I have made some custom extensions for gradients (not shown here), but they are not important, you can ignore them.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
// body
var body: some View {
// background image
let image = Image("some_image"))
.resizable()
.scaledToFit()
.frame(maxWidth: 300)
// image mask
let imageMask = Image(systemName: "heart.fill")
.resizable().scaledToFit().frame(width: 100).opacity(0.8)
// text mask
let textMask = Text("SwiftUI is Awesome!")
.font(.title).fontWeight(.bold)
return HStack {
VStack {
// my custom extension (not important)
Gradient.vertical (.red , .orange)
Gradient.horizontal(.green, .blue )
Gradient.diagonal (.pink , .purple)
image
.overlay(imageMask.border(Color.blue), alignment: .topLeading)
.border(Color.green)
image.mask(imageMask)
}.border(Color.blue)
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
and the result is:
My question is: why does the image keep its aspect ratio, but not its frame (the green border)?

scaledToFit() is forcing the image to maintain its aspect ratio while fitting inside the parent view, which in its case is the frame.
However, the frame will try to extend as much as you allow it, i.e. 300, which is more than the width of the scaled down image.
You can observe this even further by setting a maxHeight: 100

Related

Dynamically set frame dimensions in a view extension

I've been playing around with giving views a gradient shadow (taken from here and here) and while these achieve most of what I need, they seem to have a flaw: the extension requires you to set a .frame height, otherwise the gradient looks really desaturated (as it's taking up the entire height of the device screen). It's a little hard to describe, so here's the code:
struct RainbowShadowCard: View {
#State private var cardGeometryHeight: CGFloat = 0.0
#State private var cardGeometryWidth: CGFloat = 0.0
var body: some View {
VStack {
Text("This is a card, it's pretty nice. It has a couple of lines of text inside it. Here are some more lines to see how it scales.")
.font(.system(.body, design: .rounded).weight(.medium))
}
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background {
GeometryReader { geo in
Color.black
.onAppear {
cardGeometryHeight = geo.size.height
cardGeometryWidth = geo.size.width
print("H: \(cardGeometryHeight), W: \(cardGeometryWidth)")
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
.multicolorGlow(cardHeight: cardGeometryHeight, cardWidth: cardGeometryWidth)
}
}
extension View {
func multicolorGlow(cardHeight: CGFloat, cardWidth: CGFloat) -> some View {
ZStack {
ForEach(0..<2) { i in
Rectangle()
.fill(
LinearGradient(colors: [
.red,
.green
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
// The height of the frame dictates the saturation of
// the linear gradient. Without it, the gradient takes
// up the full width and height of the screen, resulting in
// a washed out / desaturated gradient around the card.
.frame(height: 300)
// My attempt at making the height and width of this view
// be based off the parent view
/*
.frame(width: cardWidth, height: cardHeight)
*/
.mask(self.blur(radius: 10))
.overlay(self.blur(radius: 0))
}
}
}
}
struct RainbowShadowCard_Previews: PreviewProvider {
static var previews: some View {
RainbowShadowCard()
}
}
I've managed to successfully store the VStack height and width in cardGeometryHeight and cardGeometryWidth states respectfully, but I can't figure out how to correctly pass that into the extension.
In the extension, if I uncomment:
.frame(width: cardWidth, height: cardHeight)
The VStack goes to a square of 32x32.
Edit
For the sake of clarity, the above solution "works" if you don't use a frame height value for the extension, but it doesn't work very nicely. Compare the saturation of the shadow in this image to the original, and you'll see a big difference between a non framed approach and a framed approach. The reason for this muddier gradient is the extension is using the screen bounds for the linear gradient, so our shadow gradient isn't getting the benefit of the "start" and "end" saturation of the red and green, but the middle blending of the two.

Adjust Image Size of Image to Parent View Size

I just want to simply adjust the image size of image to the parent's view size.
Images in swiftUI are miss behaved children that simply will not adjust to their parent...
I wanna be able to call ImageCard("image").frame(decide the size of the image)
struct ImageCard: View {
let backgoundImage: String?
var body: some View {
ZStack{
Image(backgoundImage!)
.resizable() // for resizing
.scaledToFit() // for filling image on ImageView
.cornerRadius(5)
.shadow(color: .gray, radius: 6, x: 0, y: 3)
}
}
}
If I understood correctly the intention was to fill proportionally, so
ZStack{
Image(backgoundImage!)
.resizable() // for resizing
.scaledToFill() // << here !! // for filling image on ImageView
but in that case it can spread out of bounds, so it needs to be clipped in place of .frame applied, so either
ImageCard("image")
.frame(decide the size of the image)
.clipped() // << here !!
or, better, as already described inject dimension inside card and apply it there, like
Image(backgoundImage!)
.resizable() // for resizing
.scaledToFill() // << here !!
.frame(decide the size of the image)
.clipped() // << here !!
.cornerRadius(5)
.shadow(color: .gray, radius: 6, x: 0, y: 3)
}
I wrote a package just for jmages, You give it the max height & width without needing the Image's exact dimensions & it'll maximize the size without being stretched: https://github.com/NoeOnJupiter/SImages
Usage:
DownsampledImage(.wrapped(UIImage(named: backgroundImage)))
.resizable(.wrapped(true))
.frame(width: width, height: height)
Plus this will downsample your image to the size it's displayed in, your memory usage will be much lower since it depends on the resolution of the Image.
Note: If you wanna bound the image to the whole View, use UIScreen.main.bounds for the frame.
You can simply include two variables for width and height to make your ImageCard() become adjustable at any time since you mentioned that you wanted:
I wanna be able to call ImageCard("image").frame(decide the size of the image)
You don't need ZStack or scaleToFit() because you wanted to decide the size whenever the ImageCard() is called. Code is below the image.
struct DemoView: View {
var body: some View {
ImageCard(backgroundImage: "Swift", width: 300, height: 500)
}
}
struct ImageCard: View {
let backgroundImage: String?
let width: CGFloat
let height: CGFloat
var body: some View {
Image(backgroundImage ?? "")
.resizable()
.frame(width: width, height: height)
}
}

How to align a view bottom trailing, overlaid on another view that clips over the edge of the screen in SwiftUI?

I have a view which consists of a background image/animation and a foreground control buttons. I want to .overlay the buttons on top of the background, to be aligned at the bottom leading, center, and trailing - see screenshot below (doesn't show the actual background).
However, the background has to be an animation. I'm using a wrapper view around AVPlayer for this. The video that gets played is portrait, but it gets filled to landscape as is thus wider than screen. I need it to fill the vertical space of the screen. That means that I have to use .frame(maxWidth: .infinity, maxHeight: .infinity).scaledToFill().
That leaves vertical alignment just fine - the gray top-center badge and the bottom-center button are rendered at the right places - however, it messes with the horizontal alignment. The bottom-right button aligns itself w.r.t. the video instead of the screen, and is thus aligned out of the bounds of the screen (see the second image).
I need the background to fill the screen, and the overlay to be aligned properly. The videos are recorded portrait, but AVPlayer makes them landscape with black filling on the sides, so unless that can be tweaked, I can't change the videos' aspect-ratios.
The most beneficial thing for me would be to learn how to align components w.r.t. the screen, not the parent in the overlay stack. Is there are way to achieve that? If not, is there a workaround to fix my problem (make the buttons align properly along the horizontal)?
Code
The code below isn't the source of truth, and just generates the demos. It is provided since a gentleman in the comments politely asked for it. The images are the ultimate source of truth. The actual code is much bigger, with a mechanism of randomly (and based on app state) choosing an AVPlayer to play an mp4 video. I don't think that this should be important (encapsulation and stuff), but if it is, tell me why it affects the code structure in the comments, and I will add more details.
A function used in the demos below is:
private func bigBgImage() -> some View {
self.backgroundChooser.render()
.resizable()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scaledToFill()
}
...where backgroundChooser.render() is to be taken as a blackbox. You can just drop in a dummy Image.
Desired layout
This demo is achieved through a dummy image in a GeometryReader. This is a workaround to bound the overlays despite the bigBgImage having maxWidth: .infinity. Since GeometryReader messes with the animation alignment, I don't want to use it in the final code. Nevertheless, here's the snippet:
GeometryReader { _ in
self.bigBgImage()
}
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
Undesired layout
self.bigBgImage()
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
The mechanism behind backgroundChooser is too complex to contain in an SO question in a meaningful way. One can just drop in any image
In this scenario we should separate controls from content. A possible approach is to have independent screen layer (bound always to screen geometry), then move content into background, and controls into overlays, like
Color.clear // << always tight to screen area
//.ignoresSafeArea() // << optional, if needed
.background(self.bigBgImage()) // now independent, but not clipped
.overlay(alignment: .top) { self.title() } // << all on screen
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
So I found a workaround involving manually setting an x offset for the background.
Building on Asperi's partial answer, we add a GeometryReader and offset the background by half the width:
Color.clear
.background() {
GeometryReader { geo in
self.bigBgImage()
.offset(x: -geo.size.width / 2)
}
}
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
I found that the problem was your .frame and .scaleToFill() after the Image.
Here I have a different solution. (Code is below the image)
Use screen max width and height instead of .infinity with overlay
struct DemoView: View {
var body: some View {
ZStack {
bigBigImage
.overlay(alignment: .top) {
Button("Top") {
}
.padding()
.background(.black)
.cornerRadius(10)
}
.overlay(alignment: .bottom) {
Button("Center") {
}
.padding()
.background(.black)
.cornerRadius(10)
}
.overlay(alignment: .bottomTrailing) {
Button("Leading") {
}
.padding()
.background(.black)
.cornerRadius(10)
.padding(.trailing)
}
}
}
var bigBigImage: some View {
Image("Swift")
.resizable()
.scaledToFill() //here
.frame(maxWidth: UIScreen.main.bounds.width, maxHeight: UIScreen.main.bounds.height) //here
.edgesIgnoringSafeArea(.all)
}
}

Scale Text in overlay without blurring

I've got a Text() in an overlay(). After applying .scaleEffect(), the text becomes blurry/aliased:
How can I make the text remain sharp? - I want the green Rectangle and Text to scale with the yellow Rectangle
(This is a simplified version of a complex UI element with nested overlays. Moving the overlay below scaleEffect is not an option.)
import SwiftUI
struct ZoomFontView: View {
var body: some View {
Rectangle()
.frame(maxWidth: 100, maxHeight: 100)
.foregroundColor(Color.yellow)
.overlay(sub_view)
.scaleEffect(6) // Placeholder for MagnificationGesture
}
var sub_view: some View {
ZStack {
Rectangle()
.frame(maxWidth: 70, maxHeight: 70)
.foregroundColor(Color.mint)
.overlay(Text("Hello"))
}
}
}
struct ZoomFontView_Previews: PreviewProvider {
static var previews: some View {
ZoomFontView()
}
}
The scaleEffect scale view like an image, instead you have to scale background and text separately, scaling font for Text so it is rendered properly.
Here is a demo of possible approach. Tested with Xcode 13.1 / iOS 15.1
left: scale = 1 right: scale = 8
struct ZoomFontView: View {
let scale: CGFloat = 8
var body: some View {
ZStack {
Rectangle()
.frame(maxWidth: 100, maxHeight: 100)
.foregroundColor(Color.mint)
.scaleEffect(1 * scale) // Placeholder for MagnificationGesture
Text("Hello").font(.system(size: 18 * scale))
.fixedSize()
}
}
}
Both text and Images are rendered in a low level CoreAnimation layer that relies on GPU rendering (aka Metal) to display as bitmap based graphics. So if you scale a layer where the text is being rendered, it will have the same effect of scale an Image, in other words, it will upscale pixels and make it try to "anti alias" the text to make the edges smoother.
So you should not have to scale the layer directly, instead, you should scale the font in order to archive the proper result.
Text("Hello")
// scale is the scale factor you're applying to the layer.
.font(.system(size: 16 * scale))

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