iosswiftswiftuioffsetcgsize

Calculate Offset Limit of mask bounds


Hi all I'm trying to set the offset limits so that the image during the dragGesture never exceeds the outlines of the mask...

Thanks to the help of Benzy Neez I obtain a scaleOffset value that I use for the image so that the original offset takes into account the variations in size of the image but at this point I can no longer make the limits work..

I'm definitely doing something very stupid wrong but I can't find the error

I'll show you the code

@MainActor
protocol PlatformView: View {
    var horizontalSizeClass: UserInterfaceSizeClass? { get }
    var verticalSizeClass: UserInterfaceSizeClass? { get }
    var isCompact: Bool { get }
    var isRegular: Bool { get }
}

extension PlatformView {
    var isCompact: Bool { horizontalSizeClass == .compact || verticalSizeClass == .compact }
    var isRegular: Bool { horizontalSizeClass == .regular && verticalSizeClass == .regular }
}

public enum Mask {
    case circle, square, rectangle(aspectRatio: AspectRatio)
    
    var shape: AnyShape {
        switch self {
        case .circle: return AnyShape(.circle)
        case .square, .rectangle : return AnyShape(.rect)
        }
    }
    
    func size(relativeTo size: CGSize) -> CGSize {
        let isLandscape = size.width > size.height
        let reference = isLandscape ? size.height : size
            .width

        switch self {
        case .circle, .square :
            return .init(width: reference, height: reference)
        
        case .rectangle(let aspectRatio):
            return .init(width: reference, height: reference * aspectRatio.ratio)
        }
    }

    public enum AspectRatio {
        case ar4_3, ar16_9
        var ratio: CGFloat {
            switch self {
            case .ar4_3: return 4/3
            case .ar16_9: return 16/9
            }
        }
    }
}

@Observable public class CroxioManagaer {
     
    var offset: CGSize = .zero
    var lastOffset: CGSize = .zero
    var photoSize: CGSize = .zero
    var maskSize: CGSize = .zero
    
    func currentOffset(translation: CGSize) -> CGSize  {
        let currentOffset: CGSize = .init(width: translation.width / photoSize.width, height: translation.height / photoSize.height)
        return lastOffset + currentOffset
       
    }
    
    func limitedOffset(translation: CGSize) {
        let ratioX = photoSize.width / maskSize.width
        let ratioY = photoSize.height / maskSize.height
        let maxX = ((maskSize.width - photoSize.width) / 2) / ratioX
        let maxY = ((maskSize.height - photoSize.height) / 2) / ratioY
        let currentOffset = currentOffset(translation: translation)
        let width = max(-maxX, min(maxX, currentOffset.width))
        let height = max(-maxY, min(maxY, currentOffset.height))
        offset = .init(width: width, height: height)
    }
    
    func saveOffset() {
        lastOffset = offset
    }
}

extension UIImage {
    private func aspectRatio(_ size: CGSize = .zero) -> CGFloat {
        return size == .zero ? self.size.width / self.size.height : size.width / size.height
    }
        
    func adaptPhotoSize(to size: CGSize) -> CGSize {
        let photoAspectRatio = aspectRatio()
        let maskAspectRatio = aspectRatio(size)
        
        if photoAspectRatio > maskAspectRatio {
            // La foto è più larga rispetto alla maschera: scala per altezza
            let width = size.height * photoAspectRatio
            return .init(width: width, height: size.height)
        } else {
            // La foto è più alta rispetto alla maschera: scala per larghezza
            let height = size.width * photoAspectRatio
            return .init(width: size.width, height: height)
        }
    }
}

extension CGSize {
    static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
        .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
    }
}

public struct Croxio: PlatformView {
    var selectedPhoto: UIImage
    let prefersShape: Mask
    
    public init(selectedPhoto: UIImage, prefersShape: Mask = .circle) {
        self.selectedPhoto = selectedPhoto
        self.prefersShape = prefersShape
    }
    
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    @Environment(\.verticalSizeClass) var verticalSizeClass
    
    @State private var manager = CroxioManagaer()
    
    private var scaledOffset: CGSize {
        CGSize(
            width: manager.offset.width * manager.photoSize.width,
            height: manager.offset.height * manager.photoSize.height
        )
    }
    
    public var body: some View {
        ZStack {
            Image(uiImage: selectedPhoto)
                .resizable()
                .scaledToFill()
                .frame(manager.photoSize)
                .offset(scaledOffset)

            Color.primary.opacity(0.5)
                .mask {
                    Rectangle()
                        .overlay {
                            prefersShape.shape
                                .frame(manager.maskSize)
                                .blendMode(.destinationOut)
                        }
                }
        }
        .onGeometryChange(for: CGSize.self, of: { proxy in
            proxy.size
        }, action: { newValue in
            manager.maskSize = prefersShape.size(relativeTo: newValue * 0.7)
            manager.photoSize = selectedPhoto.adaptPhotoSize(to: manager.maskSize)
        })
        .ignoresSafeArea()
        .gesture(
            DragGesture()
                .onChanged({ value in
                    manager.limitedOffset(translation: value.translation)
                })
                .onEnded({ _ in manager.saveOffset() })
        )
    }
}

Solution

  • I think there are 3 problems.

    1. In the function adaptPhotoSize, the second calculation should be using division instead of multiplying:
    if photoAspectRatio > maskAspectRatio {
        // La foto è più larga rispetto alla maschera: scala per altezza
        let width = size.height * photoAspectRatio
        return .init(width: width, height: size.height)
    } else {
        // La foto è più alta rispetto alla maschera: scala per larghezza
        let height = size.width / photoAspectRatio // 👈 here
        return .init(width: size.width, height: height)
    }
    
    1. The function limitedOffset was not working correctly. Try it like this:
    func limitedOffset(translation: CGSize) {
        let maxX = (photoSize.width - maskSize.width) / (2 * photoSize.width)
        let maxY = (photoSize.height - maskSize.height) / (2 * photoSize.height)
        let currentOffset = currentOffset(translation: translation)
        let width = max(-maxX, min(maxX, currentOffset.width))
        let height = max(-maxY, min(maxY, currentOffset.height))
        offset = .init(width: width, height: height)
    }
    
    1. Photo scaling may impact the ZStack size

    The reason why you didn't notice that the photo had the wrong size before is because you were scaling it to fill the display area. You shouldn't need to use either .scaledToFill or scaledToFit, because you are setting a frame on the photo with an exact size.

    However, there is another issue.

    The photo is contained inside the ZStack. If the photo exceeds the bounds of the display then it causes the ZStack to grow, which triggers .onGeometryChange. This changes the size of the photo, which changes the size of the display... which causes continuous looping. For example, this happens when a tall image is shown on a wide screen, such as on an iPad in landscape orientation.

    To fix, I would suggest showing the photo in the background of the ZStack. Then add Color.clear to the ZStack, to make sure it fills the full screen. This way, the size of the photo does not impact the size of the ZStack, so it resolves the looping problem:

    ZStack {
        Color.clear
    
        Color.primary.opacity(0.5)
            .mask {
                // ...
            }
    }
    .background {
        Image(uiImage: selectedPhoto)
            .resizable()
            .frame(width: manager.photoSize.width, height: manager.photoSize.height)
            .offset(scaledOffset)
    }
    // + other modifiers, as before
    

    Animation