Search code examples
swiftui

Image with corner radius when alternating fill / fit


I have added an image inside a GeometryReader and I make the image to either scale to fit or scale to fill the GeometryReader depending on a boolean value (changed using a double tap on the image). The code bellow works fine.

My problem is that I cannot figure out how to use a corner radius on the image so that it is present in both fit/fill and follows the animation.

import SwiftUI

struct ContentView: View {

    @State var isFill = true

    var body: some View {
        VStack {

            Text("Before")
                .frame(maxWidth: .infinity)
                .padding()
                .background(.yellow)

            GeometryReader { geo in
                Image("rocket")
                    .resizable()
                    .aspectRatio(contentMode: isFill ? .fill : .fit)
                    .frame(width: geo.size.width, height: geo.size.height)
                    .clipped()
                    .cornerRadius(20)
                    .onTapGesture(count: 2) {
                        withAnimation() {
                            isFill.toggle()
                        }
                    }
            }
            .background(.white)

            Text("After")
                .frame(maxWidth: .infinity)
                .padding()
                .background(.yellow)

        }
        .padding()
        .background(.pink)
    }
}

#Preview {
    ContentView()
}

Here are 2 screenshots. The white behind the image is just a white color to visualize the frame of the GeometryReader.

scale to fit scale to fill


Solution

  • When you apply the modifier .cornerRadius, it applies a clip shape that is a rounded rectangle. Actually, .cornerRadius is deprecated, so it's better to apply the clip shape explicitly using .clipShape(RoundedRectangle(cornerRadius: 20)).

    A clip shape is bounded by the frame of the view it is being applied to. In your example, you are applying a frame based on the size of the GeometryReader. This works for the scaled-to-fill version, because the image fills the frame. However, when scaled to fit, the rounded corners are being applied to empty space, so you don't see them.

    Quick fix

    A quick way to fix is to clip the image before applying the frame, then clip again after applying the frame. Doing it this way, the first clip takes effect for the scaled-to-fit version, the second for scaled-to-fill:

    GeometryReader { geo in
        Image("rocket")
            .resizable()
            .aspectRatio(contentMode: isFill ? .fill : .fit)
            .clipShape(RoundedRectangle(cornerRadius: 20))
            .frame(width: geo.size.width, height: geo.size.height)
            .clipShape(RoundedRectangle(cornerRadius: 20))
            .onTapGesture(count: 2) {
                withAnimation() {
                    isFill.toggle()
                }
            }
    }
    .background(.white)
    

    This works, but you will notice that the rounded corners "wander off" during the animation.

    A better fix

    A better way to fix is to apply the clip shape just once after constraining the size of the image to a frame of appropriate size. This way, the rounded corners remain rounded during the animation. However, it requires knowing the size of the image when it is scaled to fit.

    • One way to find the size of the image is to use a hidden version of it as a placeholder, then show the visible version as an overlay. An overlay automatically adopts the frame of the underlying view.

    • The same technique can in fact be used for scaled-to-fill, using a greedy view (such as a Color) to form the footprint. This way, a GeometryReader is not needed.

    You might think that a scaled-to-fill version of the image could be used to define the footprint for the second case too. However, when scaled to fill, the image overflows the visible area, so its frame is too big. A Color works better.

    Putting it together

    • A ZStack can be used to switch between the two footprints, depending on the flag.
    • The image is shown as an overlay over the ZStack (that is, over the footprint).
    • The clip shape is then applied after the overlay.

    A working version is shown below. Use the ZStack to replace the GeometryReader in your original example:

    ZStack {
        if isFill {
            Color.clear
        } else {
            Image("rocket")
                .resizable()
                .scaledToFit()
                .hidden()
        }
    }
    .overlay {
        Image("rocket")
            .resizable()
            .aspectRatio(contentMode: isFill ? .fill : .fit)
    }
    .clipShape(RoundedRectangle(cornerRadius: 20))
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(.white)
    .onTapGesture(count: 2) {
        withAnimation() {
            isFill.toggle()
        }
    }
    

    Animation