如何在SwiftUI ScrollView中滚动到AttributedString内的特定Ayah?
问题:React Native + SwiftUI 实现古兰经应用滚动到指定Ayah
我正在开发一款古兰经应用,采用React Native框架,但滚动功能使用原生SwiftUI组件实现。需要通过ScrollViewReader将ScrollView滚动到特定的Ayah(古兰经节文)。
当前视图结构
ScrollViewReader > ScrollView > LazyVStack > VStack > AttributedText(View) > TextViewWrapper(UIViewRepresentable) > TextView(UITextView)
当前实现与问题
目前仅能实现滚动到指定页面,但需要精准滚动到特定Ayah。当前滚动代码:
func scrollToAyah(surahIndex: Int, ayahIndex: Int) { if let page = quran.pages.first(where: { $0.ayahs.contains(where: { $0.surahIndex == surahIndex && $0.index == ayahIndex }) }) { scrollManager.scrollToID = page.id } }
尝试将scrollManager.scrollToID设为Ayah的ID,期望直接滚动到该Ayah,但实际只会滚动到Ayah所在的页面。
最小复现代码
let attributedString = NSMutableAttributedString(string: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem ipsum dolor sit amet, consectetur adipiscing elit.") struct StrPage: Identifiable { let id = UUID() let attributedString: NSMutableAttributedString } class ScrollManager: ObservableObject { @Published var scrollToID: UUID? } struct MyView: View { @ObservedObject var scrollManager: ScrollManager let pages: [StrPage] = [ StrPage(attributedString: attributedString), StrPage(attributedString: attributedString), StrPage(attributedString: attributedString), StrPage(attributedString: attributedString), StrPage(attributedString: attributedString), StrPage(attributedString: attributedString), ] @State private var pageNumberInput: String = "" var body: some View { VStack { HStack { TextField("Enter page number", text: $pageNumberInput) .textFieldStyle(RoundedBorderTextFieldStyle()) .keyboardType(.numberPad) .padding() Button(action: { scrollToPageNumber() }) { Text("Go") } .padding() } .padding(.top,150) ScrollViewReader { proxy in ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 10) { ForEach(pages) { page in AttributedText(page.attributedString) .frame(height: 200) .id(page.id) } } } .onChange(of: scrollManager.scrollToID) { id in if let id = id { DispatchQueue.main.async { proxy.scrollTo(id, anchor: .top) scrollManager.scrollToID = nil } } } } } .background(Color(backgroundColor)) } func scrollToPageNumber() { guard let pageNumber = Int(pageNumberInput), pageNumber > 0, pageNumber <= pages.count else { return // 无效页码 } // 滚动到指定页码的页面 let page = pages[pageNumber - 1] scrollManager.scrollToID = page.id } } struct TextViewWrapper: UIViewRepresentable { var attributedText: NSAttributedString var maxLayoutWidth: CGFloat func makeUIView(context: Context) -> TextView { let uiView = TextView() uiView.backgroundColor = .clear uiView.textContainerInset = .zero uiView.isEditable = false uiView.isSelectable = false uiView.isScrollEnabled = false uiView.textContainer.lineFragmentPadding = 0 uiView.delegate = context.coordinator return uiView } func updateUIView(_ uiView: TextView, context: Context) { uiView.attributedText = attributedText uiView.maxLayoutWidth = maxLayoutWidth } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UITextViewDelegate { var parent: TextViewWrapper init(_ parent: TextViewWrapper) { self.parent = parent } func textViewDidChangeSelection(_ textView: UITextView) { textView.selectedTextRange = nil } } } final class TextView: UITextView { var maxLayoutWidth: CGFloat = 0 { didSet { guard maxLayoutWidth != oldValue else { return } invalidateIntrinsicContentSize() } } override var intrinsicContentSize: CGSize { guard maxLayoutWidth > 0 else { return super.intrinsicContentSize } return sizeThatFits( CGSize(width: maxLayoutWidth, height: .greatestFiniteMagnitude) ) } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { return false } } struct AttributedText: View { private let attributedText: NSAttributedString init(_ attributedText: NSAttributedString ) { self.attributedText = attributedText } var body: some View { GeometryReader { geometry in TextViewWrapper( attributedText: attributedText, maxLayoutWidth: geometry.size.width ) } } }
解决方案
核心问题是当前ScrollViewReader只能识别页面级别的id,无法定位页面内的Ayah位置。提供两种可行方案:
方案1:拆分页面为Ayah级子视图(简单直接)
把每个Ayah拆成独立的SwiftUI视图,给每个Ayah设置唯一id,让ScrollViewReader直接定位:
步骤1:重构页面布局
struct MyView: View { @ObservedObject var scrollManager: ScrollManager // 假设你的 Quran 模型包含 pages,每个 page 包含 ayahs 数组 let quran: Quran var body: some View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 5) { ForEach(quran.pages) { page in VStack(alignment: .leading, spacing: 5) { // 遍历页面内的每个Ayah,生成独立视图 ForEach(page.ayahs) { ayah in AttributedText(ayah.attributedText) .id(ayah.id) // 给每个Ayah设置唯一ID } } } } } .onChange(of: scrollManager.scrollToAyahID) { ayahID in if let id = ayahID { DispatchQueue.main.async { proxy.scrollTo(id, anchor: .top) scrollManager.scrollToAyahID = nil } } } } } }
步骤2:修改滚动方法
func scrollToAyah(surahIndex: Int, ayahIndex: Int) { // 遍历所有页面的Ayah,找到目标 if let targetAyah = quran.pages.flatMap({ $0.ayahs }).first(where: { $0.surahIndex == surahIndex && $0.index == ayahIndex }) { scrollManager.scrollToAyahID = targetAyah.id } }
注意:如果Ayah数量极大,LazyVStack的懒加载机制会自动处理性能问题,无需担心卡顿。
方案2:富文本内定位(保留原页面排版)
如果需要保留页面整体富文本排版,可通过给Ayah添加标记、计算位置实现精准滚动:
步骤1:给Ayah的富文本添加自定义标记
生成页面富文本时,为每个Ayah嵌入唯一ID属性:
// 假设你的Ayah模型 struct Ayah: Identifiable { let id: UUID let surahIndex: Int let index: Int let text: String } // 生成带Ayah标记的页面富文本 func generatePageAttributedString(with ayahs: [Ayah]) -> NSMutableAttributedString { let pageString = NSMutableAttributedString() for ayah in ayahs { let ayahAttrs: [NSAttributedString.Key: Any] = [ .init(rawValue: "ayahID"): ayah.id, // 其他样式属性 ] let ayahString = NSAttributedString(string: ayah.text + " ", attributes: ayahAttrs) pageString.append(ayahString) } return pageString }
步骤2:在TextView中获取Ayah的位置
修改TextView类,添加方法获取指定Ayah在视图中的坐标:
final class TextView: UITextView { // ... 原有代码 ... // 根据AyahID获取其在父ScrollView中的位置 func getAyahGlobalFrame(ayahID: UUID) -> CGRect? { guard let attrText = attributedText else { return nil } var targetRange = NSRange(location: 0, length: 0) // 遍历富文本找到对应Ayah的范围 attrText.enumerateAttribute(.init(rawValue: "ayahID"), in: NSRange(location: 0, length: attrText.length)) { value, range, stop in if let id = value as? UUID, id == ayahID { targetRange = range stop.pointee = true } } guard targetRange.length > 0, let textRange = textRange(from: position(from: beginningOfDocument, offset: targetRange.location)!, to: position(from: beginningOfDocument, offset: targetRange.location + targetRange.length)!) else { return nil } // 转换为相对于父ScrollView的坐标 let localRect = firstRect(for: textRange) return convert(localRect, to: superview?.superview?.superview) } }
步骤3:直接控制ScrollView滚动
获取ScrollView实例,计算偏移量后直接滚动:
struct MyView: View { @ObservedObject var scrollManager: ScrollManager let quran: Quran @State private var scrollViewRef: UIScrollView? var body: some View { // ... 其他视图 ... ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 10) { ForEach(quran.pages) { page in AttributedText(page.attributedString) .frame(height: 200) .onAppear { // 存储当前页面的TextView引用 page.textViewRef = findTextViewInViewHierarchy() } } } } .onAppear { // 获取ScrollView实例 scrollViewRef = UIApplication.shared.windows.first?.rootViewController?.view.subviews.compactMap { $0 as? UIScrollView }.first } .onChange(of: scrollManager.scrollToAyahID) { ayahID in guard let id = ayahID, let scrollView = scrollViewRef else { return } // 找到包含目标Ayah的页面及TextView let targetPage = quran.pages.first(where: { $0.ayahs.contains(where: { $0.id == id }) }) guard let textView = targetPage?.textViewRef else { return } if let ayahFrame = textView.getAyahGlobalFrame(ayahID: id) { // 计算滚动偏移量,预留顶部内边距 let targetOffset = CGPoint(x: 0, y: ayahFrame.origin.y - scrollView.contentInset.top) scrollView.setContentOffset(targetOffset, animated: true) } scrollManager.scrollToAyahID = nil } } }
内容的提问来源于stack exchange,提问作者Arsen Sh
相关产品推荐
相关产品推荐

