Swift UI 自定义滚动指示器

问题描述 投票:0回答:1

我有一些代码呈现一个自定义滚动指示器,显示当前滚动位置的字母。在滚动开始时,字母准确到滚动位置,但当我进一步向下滚动时,字母不再与滚动视图匹配。最终我想要两个功能:首先是一个适用于动态数据的准确指示器(不同的字母/部分有不同数量的内容),第二个使自定义指示器可拖动,以便可以移动滚动视图。这是我可以得到的代码的最低级别(可重现)。如果已经有一个项目可以做到这一点,我将不胜感激,谢谢。

完整的工作演示

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
}
swift swiftui scrollview
1个回答
0
投票

由于我用最少的可重现代码提出的完美体面的问题不知何故立即被无缘无故地否决了,我决定自己回答这个问题。该代码按预期工作并解决了问题,并且实际上实现了我在问题中概述的两个目标。但这并不完美,所以如果有人可以改进它,我将不胜感激。

这就是代码的工作原理:

  1. 当用户手动滚动滚动视图时,偏移量会发生变化,并且通过将偏移量除以滚动视图总高度来计算指示器位置。 (滚动视图总高度为(scrollViewSize.height - WholeSize.height))
  2. 每个部分的日期以及每个部分的最后一个元素都有重叠的几何读取器,当它们的位置发生变化时,我检查它们是否在某个范围内,如果是,则将当前指示器标签设置为它。
  3. 视图中的每个矩形(例如代表图像)都有 id“”。当用户拖动指示器时,通过将指示器偏移量除以视图高度来生成比率。然后计算视图中所有矩形的总和并乘以该比率。这给了我们一个 int 索引,它重新打印视图中要转到的第 n 个矩形。
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()
    }
}
最新问题
© www.soinside.com 2019 - 2024. All rights reserved.