Skip to content

Commit c91ed66

Browse files
committed
AutoRotatingView jitter and black safe area on iPhone 15 Pro fixed.
Debug elments removed Changelog updated
1 parent 343f3c9 commit c91ed66

5 files changed

Lines changed: 47 additions & 41 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Before upgrading, resolve any deprecation warnings from `0.9.11` — `HFlowLegac
104104
- `WidgetSize.minimumSize` and `maximumSize` are now derived from the frame tables instead of being listed separately, so a new measurement widens the range without a second edit. Maintained by hand they drifted out of step twice, and every measurement added during this release left them further behind: `.extraLargePortrait` reported a 338x450 maximum while the measured frames reached 378x611.33, and `.accessoryInline` reported 257x26 while iPad measures 374x36. Corrected values are `.small` minimum 141x141 to 133x133, `.accessoryCircular` minimum 68x68 to 53x53, `.accessoryRectangular` minimum 153x68 to 133x53.5, `.accessoryInline` maximum 257x26 to 374x36, and `.extraLargePortrait` 338x450 to a 305.5x470 minimum and a 378x611.33 maximum. The corrected visionOS frames then moved two more on their own, which is the derivation doing its job: `.accessoryRectangular` maximum is now the 208x79 Vision Pro frame rather than the 191x81.5 Apple Watch one, and `.extraLarge` minimum the 634.5x305.5 iPad canvas rather than the visionOS frame. The rule is unchanged: the smallest and largest frame across every device that supports the size, using the iPad design canvas rather than the Home Screen frame, choosing by area where no candidate wins on both axes.
105105
- `AutoRotatingView` sometimes animated a 90 degree orientation change as a 270 degree rotation the other way. The angle is now accumulated, always taking the shortest path.
106106
- `AutoRotatingView` gave its content a frame with a safe area that jumped the moment a rotation began. SwiftUI stops expanding a view into the safe area once a `rotationEffect` is applied to it, so animating content that uses `ignoresSafeArea()` would cause it to jump to a new position before animating. The content is now laid out in the full space including the safe area, with the container's own safe area, read outside the rotation where it is still correct, re-created inside it. Content that ignores the safe area is edge to edge in every orientation and content that respects it stays clear of the real unsafe regions, which lie along the content's left and right edges when it is rotated a quarter turn. Through a rotation the size of that safe area and the insets around it move directly from the values they rest at before it to the ones they rest at after it, so a half turn leaves them alone entirely and nothing grows and shrinks on the way. Content sits centred in the safe area, and moving it directly rather than turning it leaves its centre short of where the rotation puts it part way through, so the frame is positioned to make up the difference and the content stays on the axis of rotation for the whole turn. All of it follows the animated angle frame by frame rather than each value being interpolated on its own.
107+
- `AutoRotatingView` content that ignores the safe area flickered through a rotation, and on a device whose screen is an odd number of points wide, such as a 393 point iPhone, it stopped expanding altogether once the rotation settled and left that part of the screen unpainted. SwiftUI does not treat a frame as touching the edges of its container when the frame's own edges fall between whole points, and content cannot expand into a safe area it is not touching. A rotation puts the frame between points on almost every frame of the animation, which is the flicker, and on an odd width it lands between points at rest as well, which is the part that stayed. The frame's edges are now rounded onto whole points. Rounding on its own makes the frame sit still for several frames and then jump a whole point, because the correction it rounds moves only a fraction of a point per frame, so the remainder is put back as an offset while the content is turning. That offset is not part of layout and fades to nothing as the rotation settles, leaving a resting frame exactly on whole points.
107108
- The example app's `ContentView` did not build on visionOS.
108109

109110
### Documentation

‎Example/FrameUpExample/AutoRotatingViewExamples/AutoRotatingViewExample.swift‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ struct AutoRotatingViewExample: View {
7171
VStack(alignment: .leading) {
7272
Toggle("Animation", isOn: $isAnimated)
7373
Section(header: Text("Allowed Orientations:").font(.headline)) {
74-
Toggle("Portrait", isOn: $portrait)
75-
Toggle("LandscapeLeft", isOn: $landscapeLeft)
76-
Toggle("LandscapeRight", isOn: $landscapeRight)
77-
Toggle("PortraitUpsideDown", isOn: $portraitUpsideDown)
74+
HStack {
75+
VStack {
76+
Toggle("Portrait", isOn: $portrait)
77+
Toggle("PortraitUpsideDown", isOn: $portraitUpsideDown)
78+
}
79+
VStack {
80+
Toggle("LandscapeLeft", isOn: $landscapeLeft)
81+
Toggle("LandscapeRight", isOn: $landscapeRight)
82+
}
83+
}
7884
}
7985
}
8086
.padding()

‎Example/FrameUpExample/AutoRotatingViewExamples/AutoRotatingViewFullscreenExample.swift‎

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ struct AutoRotatingViewFullscreenExample: View {
8888
}
8989
.padding()
9090
.frame(maxWidth: .infinity)
91+
.background(.ultraThinMaterial)
9192
}
9293

9394
var body: some View {
9495
VStack(alignment: .leading, spacing: 20) {
95-
Text("A fullscreen AutoRotatingView gives its content the whole screen in every orientation. The gradient ignores the safe area so it goes edge to edge, the dashed frame and the labels respect it, and the content turns around the centre of the screen without stepping or drifting.")
96+
Text("A fullscreen AutoRotatingView gives its content the whole screen in every orientation. The background ignores the safe area so it goes edge to edge, the dashed frame and the labels respect it, and the content turns around the centre of the screen without stepping or drifting.")
9697

9798
Text("Rotate the device to an orientation the app does not support to see the view rotate on its own. Changing the allowed orientations rotates it without moving the device, which is the only way to see this in a simulator.")
9899

@@ -107,34 +108,11 @@ struct AutoRotatingViewFullscreenExample: View {
107108
.frame(maxHeight: .infinity, alignment: .top)
108109
.navigationTitle("Fullscreen")
109110
.fullScreenCover(isPresented: $isPresented) {
110-
GeometryReader { outer in
111-
AutoRotatingView(allowedOrientations, animation: isAnimated ? .default : nil) {
112-
rotatingContent
113-
.overlay {
114-
GeometryReader { proxy in
115-
let i = proxy.safeAreaInsets
116-
Text(String(format: "%.0f×%.0f\nT%.0f L%.0f B%.0f R%.0f",
117-
proxy.size.width, proxy.size.height,
118-
i.top, i.leading, i.bottom, i.trailing))
119-
.font(.system(size: 13, weight: .bold, design: .monospaced))
120-
.padding(4)
121-
.background(.black)
122-
.foregroundStyle(.green)
123-
.frame(maxWidth: .infinity, maxHeight: .infinity)
124-
}
125-
}
126-
}
127-
.overlay(alignment: .bottom) {
128-
let i = outer.safeAreaInsets
129-
Text(String(format: "container %.0f×%.0f T%.0f L%.0f B%.0f R%.0f",
130-
outer.size.width, outer.size.height,
131-
i.top, i.leading, i.bottom, i.trailing))
132-
.font(.system(size: 12, weight: .bold, design: .monospaced))
133-
.padding(4).background(.black).foregroundStyle(.yellow)
134-
}
135-
.overlay(alignment: .bottom) {
136-
controls
137-
}
111+
AutoRotatingView(allowedOrientations, animation: isAnimated ? .default : nil) {
112+
rotatingContent
113+
}
114+
.overlay(alignment: .bottom) {
115+
controls
138116
}
139117
}
140118
}

‎Sources/FrameUp/AutoRotatingView/AutoRotatingView.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public struct AutoRotatingView<Content: View>: View {
8181
} else {
8282
changeAnimation = animation
8383
}
84-
withAnimation(changeAnimation?.speed(0.1)) {
84+
withAnimation(changeAnimation) {
8585
if let newInterfaceOrientation {
8686
interfaceOrientation = newInterfaceOrientation
8787
}

‎Sources/FrameUp/AutoRotatingView/RotationWithSafeAreaViewModifier.swift‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ internal struct RotationWithSafeAreaViewModifier: ViewModifier, Animatable {
6363
.rotationEffect(angle)
6464
.frame(roundedFrameSize)
6565
.position(position)
66+
.offset(positionOffset)
6667
}
6768

6869
/// The frame, with its width and height rounded to whole points.
@@ -72,22 +73,42 @@ internal struct RotationWithSafeAreaViewModifier: ViewModifier, Animatable {
7273
CGSize(width: frameSize.width.rounded(), height: frameSize.height.rounded())
7374
}
7475

75-
/// Where to put the centre of the frame so the safe area keeps the centre the rotation gives it, rounded so the frame's edges land on whole points.
76+
/// Where to put the centre of the frame so the safe area keeps the centre the rotation gives it.
7677
///
77-
/// The centre of the safe area moves directly along with its insets, so part way through a turn it is not where turning it would have put it. Moving the frame by the difference puts it back. Rounding then moves it by up to half a point, which is far less than the drift it is correcting.
78-
var position: CGPoint {
78+
/// The centre of the safe area moves directly along with its insets, so part way through a turn it is not where turning it would have put it. This puts it back.
79+
private var exactPosition: CGPoint {
7980
/// Where the centre of the safe area lands once the rotation is applied, and where it needs to land.
8081
let center = insets.centerOffset(layoutDirection: layoutDirection).rotated(by: angle)
8182
let target = containerSafeAreaInsets.centerOffset(layoutDirection: layoutDirection)
82-
let wanted = CGPoint(
83+
84+
return CGPoint(
8385
x: containerSize.width / 2 + target.x - center.x,
8486
y: containerSize.height / 2 + target.y - center.y
8587
)
86-
/// Positioning is by centre, so the centre is moved to wherever puts the frame's edges on whole points.
88+
}
89+
90+
/// ``exactPosition``, moved to wherever puts the frame's edges on whole points. Positioning is by centre, so it is the edges that are rounded rather than the centre itself.
91+
var position: CGPoint {
8792
let size = roundedFrameSize
8893
return CGPoint(
89-
x: (wanted.x - size.width / 2).rounded() + size.width / 2,
90-
y: (wanted.y - size.height / 2).rounded() + size.height / 2
94+
x: (exactPosition.x - size.width / 2).rounded() + size.width / 2,
95+
y: (exactPosition.y - size.height / 2).rounded() + size.height / 2
96+
)
97+
}
98+
99+
/// The half point or less that rounding moved the frame by, put back after layout while the content is turning.
100+
///
101+
/// Rounding the position on its own makes the frame sit still for several frames and then jump a whole point, because the correction it rounds only moves a fraction of a point per frame. Offsetting by the remainder smooths that out.
102+
///
103+
/// It only exists to smooth motion, so it fades out as the rotation settles and is nothing at all at a quarter turn. A resting frame is left exactly where rounding put it, on whole points, which is where content that ignores the safe area needs it to be able to expand.
104+
var positionOffset: CGSize {
105+
/// How far past the nearest quarter turn the content is, from 0 at rest to 1 once it is two degrees past.
106+
let pastQuarterTurn = angle.degrees - (angle.degrees / 90).rounded() * 90
107+
let turning = min(abs(pastQuarterTurn) / 2, 1)
108+
109+
return CGSize(
110+
width: (exactPosition.x - position.x) * turning,
111+
height: (exactPosition.y - position.y) * turning
91112
)
92113
}
93114
}

0 commit comments

Comments
 (0)