Last active
January 29, 2023 20:33
-
-
Save gromwel/eff58221e4a43baa146c558ee7f968a1 to your computer and use it in GitHub Desktop.
Пагинированные горизонтальные карточки
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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