Search code examples
iosswiftswiftuiuiimageuikit

Crop UIImage to specific area across devices in SwiftUI card view


I'm trying to make a Card View (very similar to the one on the App Story Today page) in SwiftUI. Each card has an image, some text below it, and rounded edges.

Each card image is 600 pixels by 400 pixels. On each image, there is a specific place I want to crop to, and it varies from image to image. For example, in the image below (and this is the image I use for this post), it is roughly the center of the plate (i.e. when I crop the image for the card, I want to preserve the plate but not the wood background). But that cropping origin/reference coordinate varies from image to image – for some it could be to the left side, right side, etc. (now that I think about it, how might I optimize for that?).

enter image description here

Here's the code that I use to generate my cards, along with the code I use to crop the images:

//  StoryView.swift

import SwiftUI

struct StoryView: View {
    @Environment(\.colorScheme) var colorScheme
    
    var story: Story
    
    var body: some View {
                RoundedRectangle(cornerRadius: self.cornerRadius)
                    .fill(self.colorScheme == .light ? Color.white : self.darkModeCardColor)
                    .frame(height: self.cardHeight)
                    .shadow(radius: self.colorScheme == .light ? 20 : 0)
                    .overlay(imageAndText())
                    .padding([.leading, .trailing], horizontalSidePadding)
        }
        
    @ViewBuilder
    private func imageAndText() -> some View {
        VStack(spacing: 0) {
            Image(uiImage: self.croppedPrimaryImage)
                .resizable()
            
            // Spacer()
            
            HStack {
                VStack(alignment: .leading) {
                    Text("Lorem Ipsum".uppercased())
                        .font(.headline)
                        .foregroundColor(.secondary)
                    
                    Text(self.story.title)
                        .font(.title)
                        .fontWeight(.black)
                        .foregroundColor(.primary)
                        .lineLimit(2)
                        .padding([.vertical], 4)
                    
                    Text("Lorem ipsum dolor sit".uppercased())
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .layoutPriority(1)
                
                Spacer()
            }
            .padding()
        }
        .cornerRadius(self.cornerRadius)
    }

        
    // MARK: - Image Cropping
        
    // TODO: - Fix this so that there are no force unwrappings
    var croppedPrimaryImage: UIImage {
        cropImage(image: story.previewImage, toRect: CGRect(x: 85, y: 0, width: cardWidth, height: 400))!
    }
    
    // see: https://stackoverflow.com/questions/31254435/how-to-select-a-portion-of-an-image-crop-and-save-it-using-swift
    func cropImage(image: UIImage, toRect: CGRect) -> UIImage? {
        // Cropping is available through CGGraphics
        let cgImage :CGImage! = image.cgImage
        let croppedCGImage: CGImage! = cgImage.cropping(to: toRect)

        return UIImage(cgImage: croppedCGImage)
    }
    
    // MARK: - Drawing Constants
    
    private let cornerRadius: CGFloat = 30
    private let cardHeight: CGFloat = 450
    private let cardWidth: CGFloat = UIScreen.main.bounds.size.width
    private let horizontalSidePadding: CGFloat = 26
    private let darkModeCardColor = Color(red: 28/255, green: 28/255, blue: 30/255)
}


struct StoryView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            StoryView(story: Story(title: "Lorem ipsum", previewImage: UIImage(imageLiteralResourceName: "img.jpg")).colorScheme(.dark)
            
            // StoryView(story: Story(title: "Lorem ipsum dolor sit amet", previewImage: UIImage(imageLiteralResourceName: "img.jpg")).colorScheme(.light)
        }
    }
}

In this line, cropImage(image: story.previewImage, toRect: CGRect(x: 85, y: 0, width: cardWidth, height: 400))! I figured out the constants that bring me to the center of the cropped plate image (definitely not the best solution, but I'm at a loss for exactly how to integrate GeometryReader into this – any ideas on that?)

Anyway, if I run it on iPhone 11 Pro Max, the card looks great:

enter image description here

But then, if I switch to iPhone SE (2nd generation), the plate is no longer centered in the card. To be expected, given that I hardcoded the points and CGRect, but how to do I fix this?

enter image description here

Again, I feel like I need to use GeometryReader here at some point, and I should store the center coordinate in pixels for each image and then work off of that.

For instance, the cropping coordinate of this image would be the point at the middle of the plate, so approx (300, 200) pixels, and then I would add and subtract a certain amount (based on available space (width and height) in card, depending on device - we get this with geometry reader) from both the x and y coordinates of the cropping coordinate to get my cropped image for the card.

I hope some of that made sense - let me know if you might have any ideas to help. I'm at a loss.


Solution

  • Just use as less hard-codings as possible and as many system-defined as possible, and then auto-layout will fit into every device.

    Here is some modified parts (added .scaledToFill and .clipShape, and removed hardcoding) - and image centered naturally. Demo & tested with Xcode 12 / iOS 14.

    demo1demo2demo3

    struct StoryView: View {
        @Environment(\.colorScheme) var colorScheme
    
        var body: some View {
                    RoundedRectangle(cornerRadius: self.cornerRadius)
                        .fill(self.colorScheme == .light ? Color.white : self.darkModeCardColor)
                        .frame(height: self.cardHeight)
                        .shadow(radius: self.colorScheme == .light ? 20 : 0)
                        .overlay(imageAndText())
                        .clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
                        .padding([.leading, .trailing])
            }
    
        @ViewBuilder
        private func imageAndText() -> some View {
            VStack(spacing: 0) {
                Image("img")
                    .resizable()
                    .scaledToFill()
    
                HStack {
                    VStack(alignment: .leading) {
                        Text("Lorem Ipsum".uppercased())
                            .font(.headline)
                            .foregroundColor(.secondary)
    
                        Text("Lorem ipsum")
                            .font(.title)
                            .fontWeight(.black)
                            .foregroundColor(.primary)
                            .lineLimit(2)
                            .padding([.vertical], 4)
    
                        Text("Lorem ipsum dolor sit".uppercased())
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                    .layoutPriority(1)
    
                    Spacer()
                }
                .padding()
            }
        }
    
    //    ... other code