Skip to content

Make view alignment not sensitive to current frame #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

pwesten
Copy link
Collaborator

@pwesten pwesten commented Feb 5, 2025

The .align() method computes an offset, and then applies that offset to the receiver's frame. This makes the result sensitive to the current frame of the receiver, rather than being an absolute computation. In certain circumstances, this can result in layout that "jitters" across a rounding threshold on each layout pass, instead of always behaving the same way. For example:

  1. A label starts out at (0,0)
  2. untransformedFrameOffset returns (0,4.83333339) to vertically center the label (note: just beyond the half-pixel boundary at 3x resolution)
  3. The offset is applied and rounded up to the nearest pixel: (0,5)
  4. On an second layout pass, untransformedFrameOffset now returns (0,-0.1666667) since it is subtracting from 5 instead of adding to 0
  5. The offset is applied, gets (0,4.83333321) and now gets rounded down to (0,4.6666667)

Fix for #145

The .align() method computes an offset, and then applies that offset to the receiver's frame. This makes the result sensitive to the *current* frame of the receiver, rather than being an absolute computation. In certain circumstances, this can result in layout that "jitters" across a rounding threshold on each layout pass, instead of always behaving the same way. For example:

1. A label starts out at (0,0)
2. untransformedFrameOffset returns (0,4.83333339) to vertically center the label (note: just *beyond* the half-pixel boundary at 3x resolution)
3. The offset is applied and rounded up to the nearest pixel: (0,5)
4. On an second layout pass, untransformedFrameOffset now returns (0,-1.666667) since it is subtracting from 5 instead of adding to 0
5. The offset is applied, gets (0,4.83333321) and now gets rounded *down* to (0,4.6666667)

Signed-off-by: Peter Westen <[email protected]>
@pwesten pwesten requested review from dfed and NickEntin February 5, 2025 19:38
/// - parameter otherView: The other view for the measurement.
/// - parameter otherPosition: The position in the `otherView`'s untransformed frame to use for the measurement.
/// - returns: The offset from the receiver's `position` to the `otherView`'s `otherPosition`.
public func untransformedFrameOrigin(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple things to consider:

  1. We should probably eliminate the untransformedFrameOffset method entirely -- but that's a breaking change
  2. If not, we ought to DRY up the implementations, of course

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I'm okay with breaking changes. We've been trending towards a breaking release for a bit now. main already has breaking changes on it – example. One big thing though is the documentation for this method does not make it clear how it differs from untransformedFrameOffset, so I'm not exactly sure from reading the doc/name how I'd migrate from one to the other. (Haven't read the implementation just yet)
  2. heh, yeah. We've been finding that difficult... been resorting to copy/paste recently

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I'm struggling with this function name + parameter names

}

let sourcePoint = receiverContext.pointInBounds(at: position)
return targetPoint.offset(by: .init(horizontal: -sourcePoint.x, vertical: -sourcePoint.y))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think this is correct for the base case of calling superview.untransformedConvert. Are there snapshot tests that work on views with transformations applied?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know what I know there. Need to defer to @NickEntin

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these last two lines are the only difference between the above method and this one, right?

)
.offset(by: offset)
.roundedToPixel(in: receiverView)
receiverView.untransformedFrame.origin = try untransformedFrameOrigin(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two ways we could add test coverage here:

  1. Add a snapshot test with two identical sibling views, calling .align() once on one of them, and twice on the other
  2. Add a unit test with a specific configuration that triggers the issue

In either event, we should probably add the test in a distinct PR, merge it first, then rebase this PR against it with the fix.

Copy link
Collaborator

@dfed dfed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to stare at this more. directionally seems reasonable but we gotta work on our method/parameter naming 😅

)

case .untransformedFrame,
.automatic /* where !targetIsInSourceSuperviewChain */:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boy oh boy do I wish I knew why this where was commented out in the code we copied.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's referencing the opposite where in the case above, probably to avoid an exhaustive-switch error (assuming the compiler isn't quite clever enough to figure that out)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, exactly. Or at least the compiler wasn't clever enough to figure this out when I wrote it like 4 compiler versions ago 😂 It may have gotten smarter about it since then.

/// - parameter otherView: The other view for the measurement.
/// - parameter otherPosition: The position in the `otherView`'s untransformed frame to use for the measurement.
/// - returns: The offset from the receiver's `position` to the `otherView`'s `otherPosition`.
public func untransformedFrameOrigin(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I'm struggling with this function name + parameter names

}

let sourcePoint = receiverContext.pointInBounds(at: position)
return targetPoint.offset(by: .init(horizontal: -sourcePoint.x, vertical: -sourcePoint.y))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these last two lines are the only difference between the above method and this one, right?

/// - parameter position: The position in the receiving view's untransformed frame.
/// - parameter otherView: The other view for the measurement.
/// - parameter otherPosition: The position in the `otherView`'s untransformed frame to use for the measurement.
/// - returns: The offset from the receiver's `position` to the `otherView`'s `otherPosition`.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, copy-pasta of the other method's comments. Needs updating

)
}

let sourcePoint = receiverContext.pointInBounds(at: position)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this line is quite right. targetPoint is defined in the superview's coordinate space, and here we're getting the sourcePoint in the receiver's coordinate space. We're missing the untransformedConvert that was here before.

Copy link
Collaborator

@NickEntin NickEntin Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(although you can't just add back the untransformedConvert since you're using the sourcePoint differently now, but I still think you need to receiver's transform in there somewhere)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants