Skip to content

Instantly share code, notes, and snippets.

@gromwel
Last active January 29, 2023 20:33
Show Gist options
  • Save gromwel/eff58221e4a43baa146c558ee7f968a1 to your computer and use it in GitHub Desktop.
Save gromwel/eff58221e4a43baa146c558ee7f968a1 to your computer and use it in GitHub Desktop.
Пагинированные горизонтальные карточки
import SwiftUI
struct ContentView: View {
var body: some View {
GeneralPagerView()
}
}
struct GeneralPagerView: View {
// актуальная страница
@State var index: Int = 0
// позиции страниц
@State var positions: [Int: CGFloat] = [:]
// горизонтальный отступ всего контента
let hPadding: CGFloat = 32
// масштаб не активных страниц
let minScale = 0.95
var body: some View {
VStack {
// пагинатор
PagerView(pageCount: 3, currentIndex: $index) {
// страницы определяем тут
ForEach(Array(0..<3), id: \.self) { idx in
// рассчет масштаба относительно размера ЭКРАНА
let halfWidth: CGFloat = (UIScreen.main.bounds.width - hPadding * 2) / 2
let percent = 1 - ((abs(positions[idx, default: 0] - halfWidth))/halfWidth)
let scale = minScale + percent * (1 - minScale)
Color.clear
.background(
// ридер считывает размеры которые предлагаются странице
GeometryReader { proxy in
Color.clear
// передача показаний о положении станицы внутри пагинатора
.preference(
key: PositionPK.self,
value: [idx: proxy.frame(in: .named("paginator")).midX]
)
}
)
// контент который будет лежать на странице
.overlay {
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
Text("\(scale)").background(.purple)
}
}
// определяем что вся страница целиком доступна для жеста
.contentShape(Rectangle())
// масштаб
.scaleEffect(scale, anchor: .center)
// границы для отладки
.border(.red)
}
}
.frame(height: 200)
// следим за изменением положения вьюх
.onPreferenceChange(PositionPK.self) {
positions = $0
}
// координатное пространство пагинатора
.coordinateSpace(name: "paginator")
.padding(.horizontal, hPadding)
Stepper("current view: \(index)", value: $index)
.padding()
}
}
}
struct PagerView<Content: View>: View {
// количесто станиц
let pageCount: Int
// весь контент, не контент по экранам
let content: Content
// отслеживание изменения индекса актуальной страницы извне
@Binding var currentIndex: Int {
didSet {
if ignore { return }
currentFloatIndex = CGFloat(currentIndex)
}
}
// отслеживание изменения индекса актуальной страницы изнутри
@State var currentFloatIndex: CGFloat = 0 {
didSet {
ignore = true
currentIndex = min(max(Int(currentFloatIndex.rounded()), 0), pageCount - 1)
ignore = false
}
}
// свойство для блокировки бесконечного цикла перерисовки
@State var ignore: Bool = false
// отступ по жесту
@GestureState private var offsetX: CGFloat = 0
// инициализация
init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self.content = content()
self._currentIndex = currentIndex
}
var body: some View {
// ридер считывает размеры которые предлагаются пагинатору
GeometryReader { proxy in
// размещаем в стеке контент
HStack(spacing: 0) {
// предлагаем контенту размеры которые были предложены пагинатору
content.frame(width: proxy.size.width)
}
// фиксируем отступы по горизонтали при переключении страницы
.offset(x: -CGFloat(currentFloatIndex) * (proxy.size.width))
// фиксируем отступы по коризонтали в момент "перетягивания"
.offset(x: offsetX)
// жест перетягивания
.highPriorityGesture(
DragGesture()
// обновляем переменную котора отслеживат перемещение по горизонтали
.updating(
$offsetX,
body: { value, state, transaction in
state = value.translation.width
}
)
// реакция при окончании жеста перетягивания
.onEnded { value in
let offset = value.translation.width / (proxy.size.width)
let offsetPredicted = value.predictedEndTranslation.width / (proxy.size.width)
let newIndex = CGFloat(currentFloatIndex) - offset
let maxIndex = pageCount - 1
currentFloatIndex = newIndex
// изменение актульной страницы с анимацией
withAnimation(.easeOut) {
// рассчеты новой актульной странцы
if offsetPredicted < -0.5 && offset > -0.5 {
currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() + 1), 0), maxIndex))
} else if offsetPredicted > 0.5 && offset < 0.5 {
currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() - 1), 0), maxIndex))
} else {
currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded()), 0), maxIndex))
}
}
}
)
}
// следим за свойством что бы перерисовать пагинатор
.onChange(of: currentIndex) { newValue in
withAnimation(.easeOut) {
currentFloatIndex = CGFloat(newValue)
}
}
}
}
// предпочтения из нижележащий вьюх которые передаются вверх по иерархии
// передаются положения вьюх по горизонтали относительно ширины ЭКРАНА
struct PositionPK: PreferenceKey {
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int : CGFloat], nextValue: () -> [Int : CGFloat]) {
for (key, val) in nextValue() {
value[key] = val
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment