Я создаю горизонтальный ScrollView
в SwiftUI, который привязывает элементы к центру экрана. Привязка работает отлично при прокрутке, но при первой загрузке представления исходный элемент немного смещается и не привязывается к центру, как ожидалось.
Я обнаружил, что проблема возникает, когда число visibleItems
четное. При нечетном числе visibleItems
первоначальное выравнивание и привязка работают правильно с самого начала.
Вот код для моего CircleScrollView
:
struct CircleScrollView: View {
@State(initialValue: 2)
var initialPosition: Int
@State(initialValue: 8)
private var visibleItems: Int
@State(initialValue: 0)
private var currentIndex: Int
private let spacing: CGFloat = 16
var body: some View {
ZStack(alignment: .leading) {
// For visuals of screen centre
Rectangle()
.fill(Color.gray.opacity(0.2))
.ignoresSafeArea()
.frame(maxWidth: UIScreen.main.bounds.width / 2, maxHeight: .infinity, alignment: .leading)
GeometryReader { geometry in
let totalSpacing = spacing * CGFloat(visibleItems - 1)
let circleSize = (geometry.size.width - totalSpacing) / CGFloat(visibleItems)
ScrollViewReader { scrollViewProxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(1..<100) { index in
ZStack {
Text("\(index)")
Circle().fill(Color(.tertiarySystemFill))
}
.frame(width: circleSize, height: circleSize)
.id(index)
}
}
.scrollTargetLayout()
.padding(.horizontal, (geometry.size.width - circleSize) / 2)
.onAppear {
scrollViewProxy.scrollTo(initialPosition, anchor: .center)
currentIndex = initialPosition
}
}
.scrollIndicators(.never)
.scrollTargetBehavior(.viewAligned)
}
}
}
}
}
Проблема:
visibleItems
четный.visibleItems
нечетное число.Предпринятые шаги:
circleSize
и пробела.Вопрос:
visibleItems
четный?Похоже, эта проблема связана с горизонтальным заполнением HStack
. Он работает без заполнения (но, очевидно, только для позиций, где оно не требуется).
Я предполагаю, что ViewAlignedScrollTargetBehavior
немного сломан. В качестве обходного пути вы можете попробовать реализовать свой собственный ScrollTargetBehavior
.
Я попробовал и обнаружил, что функция updateTarget
вызывается по-разному, в зависимости от того, первый ли это показ или в ответ на жест прокрутки:
При вызове первого показа цель имеет привязку .center
, а ширина цели равна ширине круга. Смещение по оси X целевого источника включает половину ширины экрана.
Интересно, что для первого показа коррекция не требуется. Похоже, именно здесь ViewAlignedScrollTargetBehavior
идет не так.
При последующем вызове жестов прокрутки целевая привязка равна нулю, а целевая ширина — это ширина контейнера (ScrollView
).
Нулевой якорь интерпретируется как .topLeading
. Итак, в этом случае смещение цели по оси X относится к переднему краю ScrollView
, а не к центру.
Я попробовал обновить целевую привязку до .center
и отрегулировать целевую ширину, но не смог заставить ее работать при таком подходе (он всегда прокручивался слишком сильно). Кажется, лучше всего оставить целевую привязку и ширину без изменений и признать, что они относятся к передней кромке.
Вот пользовательское поведение, специфичное для вашего макета:
struct StickyCentrePosition: ScrollTargetBehavior {
let itemWidth: CGFloat
let spacing: CGFloat
let sidePadding: CGFloat
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// dx is the distance from the target anchor to the
// leading edge of a centered item
let dx = (target.anchor?.x ?? 0) == 0
? (context.containerSize.width / 2) - (itemWidth / 2)
: 0
let currentTargetIndex = (target.rect.origin.x + dx - sidePadding) / (itemWidth + spacing)
let roundedTargetIndex = currentTargetIndex.rounded()
let scrollCorrection = (roundedTargetIndex - currentTargetIndex) * (itemWidth + spacing)
target.rect.origin.x += scrollCorrection
}
}
Поскольку вы используете переменную состояния currentIndex
для записи выбранной позиции, хорошо будет использовать ее в качестве .scrollPosition
для ScrollView
. Таким образом, он обновляется по мере прокрутки, и ScrollViewReader
не требуется. Чтобы это работало, переменную просто нужно изменить на необязательную.
Вот полностью обновленный пример, который теперь работает как для четного, так и для нечетного числа видимых элементов:
struct CircleScrollView: View {
let initialPosition = 2
let visibleItems = 8
let spacing: CGFloat = 16
@State private var currentIndex: Int?
var body: some View {
ZStack {
HStack(spacing: 0) {
Color.gray.opacity(0.2)
Color.clear
}
.ignoresSafeArea()
GeometryReader { geometry in
let screenWidth = geometry.size.width
let totalSpacing = spacing * CGFloat(visibleItems - 1)
let circleSize = (screenWidth - totalSpacing) / CGFloat(visibleItems)
let sidePadding = (screenWidth - circleSize) / 2
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(1..<100) { index in
ZStack {
Text("\(index)")
Circle().fill(Color(.tertiarySystemFill))
}
.frame(width: circleSize, height: circleSize)
.id(index)
}
}
.scrollTargetLayout()
.padding(.horizontal, sidePadding)
}
.scrollIndicators(.never)
.scrollTargetBehavior(
StickyCentrePosition(
itemWidth: circleSize,
spacing: spacing,
sidePadding: sidePadding
)
)
.scrollPosition(id: $currentIndex, anchor: .center)
.onAppear { currentIndex = initialPosition }
}
}
}
}
Я наконец-то понял, как интерпретировать значения в свитке
target
, ответ обновлен с лучшей реализацией. Теперь он правильно привязывается к ближайшему элементу (вместо того, чтобы всегда переходить к следующему), и ему не требуется снабжать флагом, указывающим, отображается ли четное или нечетное количество элементов.