我有一些代码呈现一个自定义滚动指示器,显示当前滚动位置的字母。在滚动开始时,字母准确到滚动位置,但当我进一步向下滚动时,字母不再与滚动视图匹配。最终我想要两个功能:首先是一个适用于动态数据的准确指示器(不同的字母/部分有不同数量的内容),第二个使自定义指示器可拖动,以便可以移动滚动视图。这是我可以得到的代码的最低级别(可重现)。如果已经有一个项目可以做到这一点,我将不胜感激,谢谢。
完整的工作演示
import SwiftUI
#Preview {
MemoriesView()
}
struct MemoriesView: View {
@State var characters: [CharacterMemory] = []
@State var scrollerHeight: CGFloat = 0
@State var indicatorOffset: CGFloat = 0
@State var startOffset: CGFloat = 0
@State var hideIndicatorLabel: Bool = false
@State var timeOut: CGFloat = 0.3
@State var currentCharacter: CharacterMemory = .init(value: "")
var body: some View {
NavigationView{
GeometryReader{
let size = $0.size
ScrollViewReader(content: { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0){
ForEach(characters){character in
ContactsForCharacter(character: character)
.id(character.index)
}
}
.padding(.top,15)
.padding(.trailing,20)
.offset { rect in
if hideIndicatorLabel && rect.minY < 0{
timeOut = 0
hideIndicatorLabel = false
}
// MARK: Finding Scroll Indicator height
let rectHeight = rect.height
let viewHeight = size.height + (startOffset / 2)
let scrollerHeight = (viewHeight / rectHeight) * viewHeight
self.scrollerHeight = scrollerHeight
// MARK: Finding Scroll Indicator Offset
let progress = rect.minY / (rectHeight - size.height)
// MARK: Simply Multiply With View Height
// Eliminating Scroller Height
self.indicatorOffset = -progress * (size.height - scrollerHeight)
}
}
.overlay(alignment: .topTrailing, content: {
Rectangle()
.fill(.clear)
.frame(width: 2, height: scrollerHeight)
.overlay(alignment: .trailing, content: {
Image(systemName: "bubble.middle.bottom.fill")
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.foregroundStyle(.ultraThinMaterial)
.frame(width: 45, height: 45)
.rotationEffect(.init(degrees: -90))
.overlay(content: {
Text(currentCharacter.value)
.fontWeight(.black)
.foregroundColor(.white)
.offset(x: -3)
})
.environment(\.colorScheme, .dark)
.offset(x: hideIndicatorLabel || currentCharacter.value == "" ? 65 : 0)
.animation(.interactiveSpring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.6), value: hideIndicatorLabel || currentCharacter.value == "")
})
.padding(.trailing,5)
.offset(y: indicatorOffset)
})
.coordinateSpace(name: "SCROLLER")
})
}
.navigationTitle("Contact's")
.offset { rect in
if startOffset != rect.minY{
startOffset = rect.minY
}
}
}
.onAppear {
characters = fetchCharacters()
}
.onReceive(Timer.publish(every: 0.01, on: .main, in: .default).autoconnect()) { _ in
if timeOut < 0.3{
timeOut += 0.01
} else {
print("SCrolling is Finished")
//optionally hide indicator
//hideIndicatorLabel = true
}
}
}
@ViewBuilder
func ContactsForCharacter(character: CharacterMemory)->some View{
VStack(alignment: .leading, spacing: 15) {
Text(character.value)
.font(.largeTitle.bold())
ForEach(1...4,id: \.self){_ in
HStack(spacing: 10){
Circle()
.fill(character.color)
.frame(width: 45, height: 45)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(character.color.opacity(0.6))
.frame(height: 20)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(character.color.opacity(0.4))
.frame(height: 20)
.padding(.trailing,80)
}
}
}
}
.offset(completion: { rect in
if characters.indices.contains(character.index){
characters[character.index].rect = rect
}
if let last = characters.last(where: { char in
char.rect.minY < 0
}),last.id != currentCharacter.id{
currentCharacter = last
print(currentCharacter.value)
}
})
.padding(15)
}
func fetchCharacters()->[CharacterMemory]{
let alphabets: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
var characters: [CharacterMemory] = []
characters = alphabets.compactMap({ character -> CharacterMemory? in
return CharacterMemory(value: String(character))
})
let colors: [Color] = [.red,.yellow,.pink,.orange,.cyan,.indigo,.purple,.blue]
for index in characters.indices{
characters[index].index = index
characters[index].color = colors.randomElement()!
}
return characters
}
}
extension View {
@ViewBuilder
func offset(completion: @escaping (CGRect)->())->some View{
self
.overlay {
GeometryReader{
let rect = $0.frame(in: .named("SCROLLER"))
Color.clear
.preference(key: OffsetKeyMemory.self, value: rect)
.onPreferenceChange(OffsetKeyMemory.self) { value in
completion(value)
}
}
}
}
}
struct OffsetKeyMemory: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct CharacterMemory: Identifiable {
var id: String = UUID().uuidString
var value: String
var index: Int = 0
var rect: CGRect = .zero
var pusOffset: CGFloat = 0
var isCurrent: Bool = false
var color: Color = .clear
}
由于我用最少的可重现代码提出的完美体面的问题不知何故立即被无缘无故地否决了,我决定自己回答这个问题。该代码按预期工作并解决了问题,并且实际上实现了我在问题中概述的两个目标。但这并不完美,所以如果有人可以改进它,我将不胜感激。
这就是代码的工作原理:
import SwiftUI
import Combine
struct MemoriesView: View {
@State var isScrolling: Bool = false
@State var barHeight: CGFloat = 0.0
@State var scrollToID: String = ""
@State var currentPresentedID: String = ""
@State var currentPresentedName: String = ""
@State var isDragging: Bool = false
@State var indicatorOffset: CGFloat = 0
@State var lastIndicatorOffset: CGFloat = 0
@State private var offset: Double = 0
@State var scrollViewSize: CGSize = .zero
@State var wholeSize: CGSize = .zero
var body: some View {
ZStack(alignment: .top){
GeometryReader { geo in
ScrollViewReader(content: { proxy in
ChildSizeReader(size: $wholeSize) {
ScrollView {
ZStack {
ScrollViewOffsetReader()
.onScrollingStarted {
isScrolling = true
}
.onScrollingFinished {
isScrolling = false
}
ChildSizeReader(size: $scrollViewSize) {
LazyVStack(spacing: 20){
Color.clear.frame(height: 30)
ForEach(testData) { item in
HStack {
Text(item.date).font(.title3).bold()
.id(item.id)
Spacer()
}
.overlay(GeometryReader { proxy in
Color.clear
.onChange(of: offset, { _, _ in
if !isDragging {
let frame = proxy.frame(in: .global)
let leadingDistance = frame.minY - geo.frame(in: .global).minY
if leadingDistance <= 70 && leadingDistance >= 50 {
currentPresentedID = item.id
currentPresentedName = item.date
}
}
})
})
LazyVGrid(columns: Array(repeating: GridItem(spacing: 3), count: 3), spacing: 3) {
ForEach(0..<item.countData, id: \.self) { i in
if i == (item.countData - 1) {
Rectangle()
.id("\(item.id)\(i)")
.frame(height: 160)
.foregroundStyle(.gray).opacity(0.4)
.overlay(GeometryReader { proxy in
Color.clear
.onChange(of: offset, { _, _ in
if !isDragging {
let frame = proxy.frame(in: .global)
let leadingDistance = frame.minY - geo.frame(in: .global).minY
if leadingDistance <= 250 && leadingDistance >= 200 {
currentPresentedID = item.id
currentPresentedName = item.date
}
}
})
})
} else {
Rectangle()
.id("\(item.id)\(i)")
.frame(height: 160)
.foregroundStyle(.gray)
.opacity(0.4)
}
}
}
}
Color.clear.frame(height: 30)
}
.padding(.horizontal, 5)
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
offset = value
}
}
}
}
.scrollIndicators(.hidden)
.coordinateSpace(name: "scroll")
.onChange(of: scrollToID) { _, newValue in
if !newValue.isEmpty {
withAnimation(.easeInOut(duration: 0.1)){
proxy.scrollTo(newValue, anchor: .top)
}
}
}
}
})
}
GeometryReader(content: { geometry in
HStack {
Spacer()
indicator(height: geometry.size.height)
.padding(.trailing, 8)
.onChange(of: offset) { _, new in
if !isDragging {
let fullSize = scrollViewSize.height - wholeSize.height
let ratio = new / fullSize
let maxClampedRatio = max(0.0, ratio)
let minClampedRatio = min(1.0, maxClampedRatio)
let newOffset = minClampedRatio * geometry.size.height
let max = geometry.size.height - 50.0
let clampedOffset = min(newOffset, max)
self.indicatorOffset = clampedOffset
self.lastIndicatorOffset = clampedOffset
}
}
}
}).padding(.top, 40).padding(.bottom, bottom_Inset() + 20)
ZStack {
HStack {
Image(systemName: "chevron.down")
.font(.title3).bold()
Spacer()
}
HStack {
Spacer()
Text("Memories").font(.title).bold()
Spacer()
}
}
.padding(.horizontal).padding(.bottom, 5)
.background(.ultraThickMaterial)
}
.ignoresSafeArea(edges: .bottom)
.onAppear {
if currentPresentedName.isEmpty {
currentPresentedName = testData.first?.date ?? "May 2021"
}
}
}
@ViewBuilder
func indicator(height: CGFloat) -> some View {
ZStack(alignment: .topTrailing){
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.gray).frame(width: 2)
.opacity(isScrolling ? 1.0 : 0.3)
HStack(spacing: isDragging ? 50 : 5){
Text(currentPresentedName)
.font(.subheadline).bold()
.padding(.horizontal, 8).padding(.vertical, 4)
.foregroundStyle(.white)
.background(Color(red: 5 / 255, green: 176 / 255, blue: 255 / 255))
.clipShape(Capsule())
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(Color(red: 5 / 255, green: 176 / 255, blue: 255 / 255))
.frame(width: 6, height: 50)
}
.background(Color.gray.opacity(0.0001))
.offset(x: 1.5)
.offset(y: indicatorOffset).opacity(isScrolling ? 1.0 : 0.0)
.gesture(
DragGesture()
.onChanged({ value in
if !isDragging {
withAnimation(.easeInOut(duration: 0.2)){
isDragging = true
}
}
if value.location.y >= 25.0 && value.location.y < (height - 25) {
indicatorOffset = value.translation.height + lastIndicatorOffset
let ratio = indicatorOffset / height
let maxClampedRatio = max(0.0, ratio)
let minClampedRatio = min(1.0, maxClampedRatio)
let totalCount = CGFloat(testData.reduce(0) { $0 + $1.countData })
let pickedElement = Int(ratio * totalCount)
var cumulativeCount = 0
var finalID: String?
var finalIdx: Int?
for data in testData {
if (cumulativeCount + data.countData) > pickedElement {
finalID = data.id
finalIdx = pickedElement - cumulativeCount
currentPresentedID = data.id
currentPresentedName = data.date
break
} else {
cumulativeCount += data.countData
}
}
if let f1 = finalID, let f2 = finalIdx {
scrollToID = f1 + "\(f2)"
}
}
})
.onEnded({ value in
withAnimation(.easeInOut(duration: 0.2)){
isDragging = false
}
lastIndicatorOffset = indicatorOffset
})
)
}
.frame(height: height)
}
}
let testData = [
testMonthData(date: "May 2020", countData: 2),
testMonthData(date: "June 2021", countData: 2),
testMonthData(date: "July 2022", countData: 20),
testMonthData(date: "Mar 2023", countData: 1),
testMonthData(date: "Apr 2024", countData: 9),
testMonthData(date: "Aug 2025", countData: 2),
testMonthData(date: "Sep 2026", countData: 6),
testMonthData(date: "Nov 2027", countData: 12),
testMonthData(date: "Dec 2028", countData: 8),
testMonthData(date: "Jan 2029", countData: 9),
testMonthData(date: "Jul 2030", countData: 10),
testMonthData(date: "Oct 2031", countData: 13),
testMonthData(date: "Nov 2032", countData: 12),
testMonthData(date: "Mar 2033", countData: 25),
testMonthData(date: "June 2034", countData: 2),
testMonthData(date: "June 2035", countData: 15),
testMonthData(date: "June 2036", countData: 5)
]
struct testMonthData: Identifiable {
var id = UUID().uuidString
var date: String
var countData: Int
}
struct ScrollViewOffsetReader: View {
private let onScrollingStarted: () -> Void
private let onScrollingFinished: () -> Void
private let detector: CurrentValueSubject<CGFloat, Never>
private let publisher: AnyPublisher<CGFloat, Never>
@State private var scrolling: Bool = false
init() {
self.init(onScrollingStarted: {}, onScrollingFinished: {})
}
private init(
onScrollingStarted: @escaping () -> Void,
onScrollingFinished: @escaping () -> Void
) {
self.onScrollingStarted = onScrollingStarted
self.onScrollingFinished = onScrollingFinished
let detector = CurrentValueSubject<CGFloat, Never>(0)
self.publisher = detector
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.detector = detector
}
var body: some View {
GeometryReader { g in
Rectangle()
.frame(width: 0, height: 0)
.onChange(of: g.frame(in: .global).origin.y) { _, offset in
if !scrolling {
scrolling = true
onScrollingStarted()
}
detector.send(offset)
}
.onReceive(publisher) { _ in
scrolling = false
onScrollingFinished()
}
}
}
func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
.init(
onScrollingStarted: closure,
onScrollingFinished: onScrollingFinished
)
}
func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
.init(
onScrollingStarted: onScrollingStarted,
onScrollingFinished: closure
)
}
}
#Preview {
MemoriesView()
}
struct ChildSizeReader<Content: View>: View {
@Binding var size: CGSize
let content: () -> Content
var body: some View {
ZStack {
content().background(
GeometryReader { proxy in
Color.clear.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}