Don’t be fooled by the title. Writing code where a given value is stored in two different ways (in two different places) feels wrong to me too. But at the moment, it feels like solving my current problem with a single source of truth would result in more complex code.
Background; What am I trying to do?
In my ZoomBurst photo editing extension, currently the zooming and rotation effects use the centre of the image rect as the centre of the effect. For quite some time, I’ve been curious about what it would take to add the ability to allow users to specify a centre point for the effects. (My currently thinking would be to let users specify a single point to use as the centre for both zoom and rotation. But never say never…)
How am I Trying to Do it?
In broad strokes, two things need to happen to add this feature.
- add UI to allow users to specify the custom centre point
- CoreImage filters need to be updated to support effectCenter not being the same as imageCenter
For the UI, I’m adding an overlay marker to the output image. Users can change the centre by dragging this marker around. In swiftUI marker position is updated using the .offset() function in View.
I defined a @State variable to store the offset. It will be (0,0) at the top left corner and (imageSize.width, imageSize.height) at the bottom right.
@State var currentOffset: CGSize = .zero
func marker(inside size: CGSize) -> some View {
return Circle()
.stroke(.black, lineWidth: 10)
.stroke(.white, lineWidth: 6)
.frame(width: Self.markerDimension)
.offset(currentOffset)
}
So far so good. However the CoreImage code cannot use this because imageSize will be different for preview output vs final image output. So the CombineOptions structure needs to store the centre value using a UnitVector size (ie height and width will be in the range [0..1])
While it’s technically possible to rely on the unitVector size value in CombineOptions, this would create the need for quite a bit of translating between [0..1] and [0..imageSize] First there is the value passed to the offset function. But there is also a surprising amount of logic to prevent users from dragging the marker off the side of the preview image.
DragGesture()
.onChanged { gesture in
let proposedOffset = gesture.translation + baseOffset
currentOffset = proposedOffset.validSize(using: markerRect)
func validSize(using rect: CGRect) -> CGSize {
if rect.contains(self.asPoint) {
return self
}
var result: CGSize = self
if self.width < 0 {
result.width = 0
} else if self.width > rect.width {
result.width = rect.width
}
if self.height < 0 {
result.height = 0
} else if self.height > rect.height {
result.height = rect.height
}
return result
}
Should users be able to drag the marker so that it half off the preview image? (ie the centre is right on the image edge?) Or will they be ok only dragging the marker to the point where it is still entirely overtop of the preview image. All of this code requires adding and subtracting half the width of the marker view in a surprising number of places. The code to do this is much easier to understand if it all takes place in the [0..imageSize] domain, rather than the [0..1] domain.
I’m not saying the more complex thing couldn’t be done. But I was imagining somebody coming into this code 6 months or 6 years from now. I feared that the brain power to understand the code jumping between the two domains would beg the question… ‘why is this jumping back and forth between [0..imageSize] and [0..1] so much?’
In order to minimize the chance of the two values getting out of sync I created a single place in the code where one value gets changed, and it updates the [0..1] value. Come to think of it, this would be a good excuse to add a Psst comment explaining this conundrum to future me or anyone else that happens to have the good fortune to be reading this code in the future. Maybe even link to this post.
Another thought I’ve just had (and don’t think I have enough functioning neurons this late at night to properly tackle) is how would something like this fly in an environment where others are reviewing my code. I feel like there would be a (justifiable) tendency among reviewers to be skeptical of my decision to use two sources of truth. I also suspect my attempts to defend it would be more qualitative than quantitative. And I’m not entirely sure how it would be resolved. I’d like to think it would be more than just a battle of wills because I’m not a fan of battles of wills.