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() })
)
}
}
I think there are 3 problems.
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)
}
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)
}
ZStack
sizeThe 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