Skip to content

Instantly share code, notes, and snippets.

@importRyan
Last active September 19, 2022 16:55
Show Gist options
  • Save importRyan/09ea5025468bca4b3f5fbd3b2999e3dc to your computer and use it in GitHub Desktop.
Save importRyan/09ea5025468bca4b3f5fbd3b2999e3dc to your computer and use it in GitHub Desktop.
iOS 16.0 bug: Unwanted vertical layout displacement (FB11545578)

Neighboring buttons dip down a few pixels in iOS 16 (but not 15.5), despite a ButtonStyle that changes only opacity.

Comparison

UnexplainedVerticalMicroMovement.mp4

iOS 16.0 Larger

ProblematicPress.mov

Xcode: Version 14.0 (14A309)

macOS: 12.6 (21G115)

Simulator: Version 14.0 (986.3) SimulatorKit 624 CoreSimulator 857.7

import OrderedCollections
import SwiftUI
@main
struct HorizontalScrollingPickerApp: App {
@State
private var selection = SomeChoices.photo
var body: some Scene {
WindowGroup {
VStack(alignment: .center) {
TextCarouselPicker(
selection: $selection,
labels: { $0.name.uppercased() },
spacing: 15.0
)
.accentColor(.yellow)
.font(.caption.weight(.medium))
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Rectangle().frame(width: 1).opacity(0.2)
Spacer()
}
}
.preferredColorScheme(.dark)
}
}
}
// MARK: - Model
enum SomeChoices: Int, PickerSelectable {
case slomo, video, photo, square, pano
var id: Self { self }
var name: String {
switch self {
case .slomo: return .init("Slo-Mo")
case .video: return .init("Video")
case .photo: return .init("Photo")
case .square: return .init("Square")
case .pano: return .init("Pano")
}
}
}
protocol PickerSelectable: CaseIterable & Hashable & Identifiable {}
// MARK: - Picker
struct TextCarouselPicker<T: PickerSelectable>: View {
init(
selection: Binding<T>,
labels: @escaping (T) -> String,
orderedChoices: Array<T> = T.allCases.map { $0 },
spacing: CGFloat
) {
self.choices = orderedChoices
self.labels = labels
self.spacing = spacing
_selection = selection
_widths = State(
initialValue:
OrderedDictionary(
uncheckedUniqueKeys: orderedChoices,
values: Array(repeating: 0, count: choices.count)
)
)
}
@Binding
private var selection: T
private let choices: Array<T>
private let labels: (T) -> String
@State
private var widths: OrderedDictionary<AnyHashable, CGFloat>
private let spacing: CGFloat
var body: some View {
Color.clear
.hidden()
.overlay(alignment: .center) {
alignedButtonsRow
.frame(maxWidth: .infinity)
.mask(horizontalMask)
.accessibilityRepresentation {
accessibilityRepresentation
}
}
}
}
private extension TextCarouselPicker {
var alignedButtonsRow: some View {
HStack(alignment: .center, spacing: spacing) {
ForEach(choices) { number in
buildButton(number)
}
}
.alignmentGuide(HorizontalAlignment.center) { d in
d[HorizontalAlignment.leading] + alignedButtonsRowOffset
}
.onPreferenceChange(OrderedWidthsPreferenceKey.self) { newValue in
withAnimation {
widths.merge(newValue) { $1 }
}
}
}
var alignedButtonsRowOffset: CGFloat {
let selectedIndex = widths.index(forKey: selection)!
let buttonWidths = widths.elements.values[..<selectedIndex].reduce(0, +)
// Raw value is zero-based (effectively: spacing * items.count - 1)
let interButtonSpacing = spacing * CGFloat(selectedIndex)
let centerOfCurrentSelection = widths.elements[selectedIndex].value / 2
return buttonWidths + interButtonSpacing + centerOfCurrentSelection
}
func buildButton(_ choice: T) -> some View {
Button(
action: {
withAnimation(.easeInOut(duration: 0.25)) {
selection = choice
}
},
label: {
Text(labels(choice))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: true)
.foregroundStyle(selection == choice ? Color.accentColor : .secondary)
}
)
.buttonStyle(NoMovementButtonStyle())
.background {
Measure<OrderedWidthsPreferenceKey> { value, geometry in
value[choice] = geometry.size.width
}
}
}
var horizontalMask: some View {
LinearGradient(
stops: [
.init(color: .black.opacity(0), location: 0.05),
.init(color: .black.opacity(1), location: 0.25),
.init(color: .black.opacity(1), location: 0.75),
.init(color: .black.opacity(0), location: 0.95)
],
startPoint: .leading,
endPoint: .trailing
)
}
var accessibilityRepresentation: some View {
Picker(selection: $selection) {
ForEach(choices) { choice in
Text(labels(choice))
.tag(choice)
}
} label: {
EmptyView()
}
}
}
struct NoMovementButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.8 : 1)
}
}
// MARK: - View Width Measurement
public struct Measure<Key: PreferenceKey>: View {
public init(
_ transform: @escaping (_ value: inout Key.Value, _ geometry: GeometryProxy) -> Void
) {
self.transform = transform
}
private let transform: (inout Key.Value, GeometryProxy) -> Void
public var body: some View {
GeometryReader { proxy in
Color.clear
.transformPreference(Key.self) { transform(&$0, proxy) }
}
}
}
fileprivate struct OrderedWidthsPreferenceKey: PreferenceKey {
static var defaultValue: Dictionary<AnyHashable, CGFloat> = [:]
static func reduce(
value: inout Dictionary<AnyHashable, CGFloat>,
nextValue: () -> Dictionary<AnyHashable, CGFloat>
) {
value.merge(nextValue()) { $1 }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment