Search code examples
swiftuiswiftui-asyncimage

AsyncImage with a placeholder sizing to fit to aspect ratio and clipping


I'm trying to create an AsyncImage with a placeholder image. With the following constraints...

  1. The placeholder image may not have the same aspect ratio as the AsyncImage.
  2. The remote image may not have the same aspect ratio as the AsyncImage.
  3. The remote and placeholder image may not have the same aspect ratios.
  4. The aspect ratio of the AsyncImage is fixed.

The requirements...

  1. The placeholder and the remote image must be scaled to fill the aspect ratio of the AsyncImage.
  2. They must not be squashed.
  3. They must be clipped so that they do not overflow the bounds of the AsyncImage (set by the aspect ratio).

In my head this should be simple. 😅

But I just cannot get this to work without at least one of the requirements breaking.

This is the closest I've got so far but the AsyncImage is just not clipped so overflows beyond either the height or width.

You can paste this into Xcode and it will show the

import SwiftUI

let aspect = 1.5

struct TestView: View {
    var body: some View {
        AsyncImage(
            url: URL(string: "https://placekitten.com/1920/1080"),
            transaction: .init(animation: .easeIn(duration: 3)) // <- slow animation to show issue
        ) { phase in
            switch phase {
            case .success(let image):
                image
                    .resizable()
                    .scaledToFill()
                    .aspectRatio(aspect, contentMode: .fill)
                    .clipped()

            default:
                Image(systemName: "rectangle")
                    .resizable()
                    .scaledToFill()
                    .aspectRatio(aspect, contentMode: .fill)
                    .clipped()
                    .foregroundColor(.white)
            }
        }
        .aspectRatio(aspect, contentMode: .fill)
    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            TestView()
                .overlay {
                    // This overlay shows the correct rect but the image is just not cclipped.
                    Color.black
                        .opacity(0.5)
                        .aspectRatio(aspect, contentMode: .fit)
                }
                .padding()
                .aspectRatio(aspect, contentMode: .fit)
        }
        .background(Color.red)
        .padding()
    }
}

This gives me the following...

enter image description here

The red background is from the VStack. The black transparent overlay shows the aspect ratio I'm looking for based on the aspect set at the top of the file.


Solution

  • OK... I kept battling on with this and found an answer that doesn't require GeometryReader.

    I created this extension on View.

    extension View {
        public func framedAspectRatio(_ aspect: CGFloat? = nil, contentMode: ContentMode) -> some View where Self == Image {
            self.resizable()
                .fixedAspectRatio(contentMode: contentMode)
                .allowsHitTesting(false)
        }
    
        public func fixedAspectRatio(_ aspect: CGFloat? = nil, contentMode: ContentMode) -> some View {
            self.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                .aspectRatio(aspect, contentMode: contentMode)
                .clipped()
        }
    }
    

    And now with this code...

    struct TestView: View {
        var body: some View {
            AsyncImage(
                url: URL(string: "https://placehold.co/300x1600/png"),
                transaction: .init(animation: .easeIn(duration: 3)) // <- slow animation to show issue
            ) { phase in
                switch phase {
                case .success(let image):
                    image.framedAspectRatio(contentMode: .fill)
    
                default:
                    Image(systemName: "checkmark.rectangle")
                        .clipped()
                        .foregroundColor(.white)
                }
            }
        }
    }
    
    struct TestView_Previews: PreviewProvider {
        static var previews: some View {
            VStack {
                Text("Fit")
    
                TestView()
                    .opacity(0.8)
                    .fixedAspectRatio(1, contentMode: .fit)
                    .frame(width: 100, height: 200)
                    .background(Color.black)
                    .padding()
    
                Text("Fill")
    
                TestView()
                    .opacity(0.8)
                    .fixedAspectRatio(1, contentMode: .fill)
                    .frame(width: 100, height: 200)
                    .background(Color.black)
                    .padding()
            }
        }
    }
    

    Everything works as expected.

    enter image description here

    enter image description here