SwiftUI 如何记录当前显示在CollectionView 上的grid item 的索引?

SwiftUI How do I record the index of the grid item that is currently being displayed on CollectionView?

我想要一个滚动的水平项目列表,其中每个项目几乎占据整个屏幕宽度 (UIScreen.main.bounds.width - 50)。应该有足够的下一个项目可见,让用户知道有东西要滚动到。我希望能够确定当前占据大部分视图的项目的索引。

主视图有三个子视图:搜索栏、地图和结果视图(这是我想要滚动水平列表的地方)。地图上的图钉需要根据当前显示的结果进行更新。

为了清晰和可重现性,我已经包含了项目中的所有代码。

主要观点:

import SwiftUI

struct ContentView: View
{
  @State var results = [[Place]]()
  @State var selectedResult = [Place]()
  
    var body: some View {
      VStack(alignment: .center) {
        SearchBar(results: $results)
          .padding()
        
        SearchMapView(result: $selectedResult)
          .frame(height: UIScreen.main.bounds.height/3)
        
        SearchResultsView(results: $results, selectedResult: $selectedResult)
        
        
        Spacer()
      }
    }
}

搜索栏:

import SwiftUI

struct SearchBar: View
{
  @State private var text: String = ""
  @Binding var results: [[Place]]
  
  var body: some View {
    HStack {
      TextField("Search", text: $text)
      
      Button(action: { findGroup() }, label: {
        Image(systemName: "magnifyingglass")
      })
    }
  }
  
  func findGroup()
  {
    var foundResults = [[Place]]()
    for vacation in vacations
    {
      var resultFound = false
            for place in vacation
      {
        if !resultFound
        {
          let name = place.name.uppercased()
          if name.contains(text.uppercased())
          {
            foundResults.append(vacation)
            resultFound = true
          }
        }
      }
      results = foundResults
    }
  }
}

地图:

import SwiftUI
import MapKit

struct SearchMapView: View
{
  // MARK: - Properties
  @State private var region = MKCoordinateRegion(
    center: CLLocationCoordinate2D(
      latitude: 37.0902,
      longitude: -95.7129
    ),
    span: MKCoordinateSpan(
      latitudeDelta: 1,
      longitudeDelta: 1
    )
  )
  
  @Binding var result: [Place]
  
  // MARK: - View
    var body: some View {
      Map(coordinateRegion: $region, annotationItems: result) { place in
        MapMarker(coordinate: CLLocationCoordinate2D(latitude: place.latitude , longitude: place.longitude ))
      }
      .onAppear {
        findCenter()
      }
      .onChange(of: result, perform: { _ in
        findCenter()
      })
      
      .ignoresSafeArea(edges: .horizontal)
    }
  
  // MARK: - Methods
  func findCenter()
  {
    if let place = result.first
    {
      region.center = CLLocationCoordinate2D(latitude: place.latitude , longitude: place.longitude )
    }
  }
}

查看结果:

import SwiftUI

struct SearchResultsView: View
{
  // MARK: - Properties
  typealias Row = CollectionRow<Int, [Place]>
  @State var rows: [Row] = []
  @State var resultDetailIsPresented: Bool = false
  @State var selectedResultNeedsUpdate: Bool = false
  
  @Binding var results: [[Place]]
  @Binding var selectedResult: [Place]
  
  // MARK: - View
  var body: some View {
    VStack(alignment: .leading) {
      HStack {
        Text("Results")
          .font(.headline)
        
        ZStack {
          Circle()
            .foregroundColor(.gray)
            .frame(width: 25, height: 25)
          
          Text("\(results.count)")
            .bold()
            .accessibility(identifier: "results count")
          
          Spacer()
        } //: Count ZStack
        .hidden(results.isEmpty)
        
      } //: Heading HStack
      .padding(.leading)
      
      Divider()
      
      if !results.isEmpty
      {
        CollectionViewUI(rows: rows) { sectionIndex, layoutEnvironment in
          createSection()
        } cell: { indexPath, result in
          if let place = result.first
          {
            button(place: place)
              .border(Color.black, width: 1)
          }
        } //: Collection View Cell
        
      } else
      {
        Text("No current results.")
          .padding(.leading)
      } // Else
      
      Spacer()
    } // Main VStack
    .onChange(of: results, perform: { _ in
      print("Results have changed.")
      fillRows()
      selectedResultNeedsUpdate = true
    })
    .onChange(of: selectedResultNeedsUpdate, perform: { value in
      if value == true // This still causes "Modifying state during view update" error, but the state saves.
      {
        updateSelection()
        selectedResultNeedsUpdate = false
      }
    })

    .sheet(isPresented: $resultDetailIsPresented, content: {
      Text("Result: \(selectedResult.first?.name ?? "Missing.")")
    })
  }
  
  // MARK: - Methods
  func fillRows()
  {
    rows = []
    
    rows.append(Row(section: 0, items: results))
  }
  
  func createSection() -> NSCollectionLayoutSection
  {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(UIScreen.main.bounds.width - 50), heightDimension: .estimated(UIScreen.main.bounds.height/3))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0)
    section.interGroupSpacing = 20
    section.orthogonalScrollingBehavior = .groupPagingCentered
    return section
  }
  
  func updateSelection()
  {
    if !results.isEmpty
    {
      selectedResult = results[0] // Temporary solution so -something- is selected
      print("Selected result \(selectedResult.first?.name ?? "missing.")")
    } else
    {
      print("Results are empty.")
    }
  }
  
  func button(place: Place) -> some View
  {
    GeometryReader { geometry in
      Button(action: {
        resultDetailIsPresented = true
        
      }) { //: Button Action
        ResultCardView(place: place)
      } //: Button Content
    } //: Geo
    .frame(maxHeight: .infinity)
    .ignoresSafeArea(.keyboard, edges: .bottom)
  }
}

extension View
{
  /// Use a Bool to determine whether or not a view should be hidden.
  /// - Parameter shouldHide: Bool
  /// - Returns: some View
  @ViewBuilder func hidden(_ shouldHide: Bool) -> some View {
    switch shouldHide
    {
      case true:
        self.hidden()
      case false:
        withAnimation {
          self.animation(.easeOut(duration: 0.5))
        }
    }
  }
}

结果卡视图

import SwiftUI

struct ResultCardView: View
{
  let screenWidth = UIScreen.main.bounds.width
  var place: Place
  
    var body: some View {
      HStack(alignment: .top) {
        
          Image(systemName: "car")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 150)
            .padding()
            .foregroundColor(.black)

        VStack(alignment: .leading) {
          Text("Place")
          
          Text("\(place.name))")
          
          Spacer()
        } //: Result Main VStack
        .padding()
      } //: Result Main HStack
      
      .frame(width: screenWidth - 50)
      .ignoresSafeArea(edges: .horizontal)
    }
}

型号

import MapKit

struct Place: Identifiable, Equatable, Hashable
{
  let id = UUID()
  var name: String
  var latitude: Double
  var longitude: Double
  
  var coordinate: CLLocationCoordinate2D {
    CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
  }
}

模拟数据

// Florida
var magicKingdom = Place(
  name: "Magic Kingdom",
  latitude: 28.4177,
  longitude: -81.5812)
var epcot = Place(
  name: "Epcot",
  latitude: 28.3747,
  longitude: -81.5494)
var buschGardens = Place(
  name: "Busch Gardens",
  latitude: 28.0372,
  longitude: -82.4194)
var universal = Place(
  name: "Universal Studios",
  latitude: 28.4754,
  longitude: -81.4677)
var animalKingdom = Place(
  name: "Animal Kingdom",
  latitude: 28.3529,
  longitude: -81.5907)

var vacation1: [Place] = [
  magicKingdom,
  epcot,
  animalKingdom]
var vacation2: [Place] = [
  magicKingdom,
  epcot,
  animalKingdom,
  buschGardens,
  universal]
var vacation3: [Place] = [epcot, buschGardens]
var vacation4: [Place] = [universal, buschGardens]
var vacation5: [Place] = [buschGardens]

// California
var appleCampus = Place(
  name: "Apple Campus",
  latitude: 37.33182,
  longitude: -122.03118)
var disneyLand = Place(
  name: "Disney Land",
  latitude: 33.8121,
  longitude: -117.9190)
var goldenGate = Place(
  name: "Golden Gate Bridge",
  latitude: 37.8199,
  longitude: -122.4783)
var alcatraz = Place(
  name: "Alcatraz",
  latitude: 37.8270,
  longitude: -122.4230)
var coit = Place(
  name: "Coit Tower",
  latitude: 37.8024,
  longitude: -122.4058)

var vacation6: [Place] = [
  appleCampus,
  disneyLand,
  goldenGate,
  alcatraz,
  coit]
var vacation7: [Place] = [disneyLand]
var vacation8: [Place] = [
  appleCampus,
  goldenGate,
  coit]
var vacation9: [Place] = [disneyLand, alcatraz]
var vacation10: [Place] = [coit, appleCampus]

var vacations: [[Place]] = [
  vacation1,
  vacation2,
  vacation3,
  vacation4,
  vacation5,
  vacation6,
  vacation7,
  vacation8,
  vacation9,
  vacation10]

这里是用UIViewRepresentable转换后的CollectionView。这是基于 Samuel Defago 的blog post

import SwiftUI

public struct CollectionViewUI<Section: Hashable, Item: Hashable, Cell: View>: UIViewRepresentable
{
  // MARK: - Properties
  let rows: [CollectionRow<Section, Item>]
  let sectionLayoutProvider: (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection
  let cell: (IndexPath, Item) -> Cell
  
  // MARK: - Initializer
  public init(rows: [CollectionRow<Section, Item>],
       sectionLayoutProvider: @escaping (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection,
       @ViewBuilder cell: @escaping (IndexPath, Item) -> Cell) {
    self.rows = rows
    self.sectionLayoutProvider = sectionLayoutProvider
    self.cell = cell
  }
  
  // MARK: - Helpers
  enum Section: Hashable
  {
    case main
  }
  
  private class HostCell: UICollectionViewCell
  {
    private var hostController: UIHostingController<Cell>?
    
    override func prepareForReuse()
    {
      if let hostView = hostController?.view
      {
        hostView.removeFromSuperview()
      }
      hostController = nil
    }
    
    var hostedCell: Cell? {
      willSet {
        guard let view = newValue else { return }
        hostController = UIHostingController(rootView: view, ignoreSafeArea: true)
        if let hostView = hostController?.view
        {
          hostView.frame = contentView.bounds
          hostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
          contentView.addSubview(hostView)
        }
      }
    }
  }
  
  public class CVCoordinator: NSObject, UICollectionViewDelegate
  {
    fileprivate typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
    
    fileprivate var isFocusable: Bool = false
    fileprivate var dataSource: DataSource? = nil
    fileprivate var rowsHash: Int? = nil
    fileprivate var sectionLayoutProvider: ((Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection)?
    
    public func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool
    {
      return isFocusable
    }
  }
  
  // MARK: - Methods
  // View instantiation
  public func makeUIView(context: Context) -> UICollectionView
  {
    let cellIdentifier = "hostCell"
    
    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout(context: context))
    collectionView.backgroundColor = .systemBackground
    collectionView.register(HostCell.self, forCellWithReuseIdentifier: cellIdentifier)
    collectionView.showsVerticalScrollIndicator = false
    
    context.coordinator.dataSource = Coordinator.DataSource(collectionView: collectionView) { collectionView, indexPath, item in
      let hostCell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as? HostCell
      hostCell?.hostedCell = cell(indexPath, item)
      return hostCell
    }
    
    reloadData(in: collectionView, context: context)
    return collectionView
  }
  
  // Updating View
  public func updateUIView(_ uiView: UICollectionView, context: Context)
  {
    reloadData(in: uiView, context: context, animated: true)
  }
  
  // Coordinator
  public func makeCoordinator() -> CVCoordinator
  {
    CVCoordinator()
  }
  
  // Create Layout
  private func layout(context: Context) -> UICollectionViewLayout
  {
    let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
      context.coordinator.sectionLayoutProvider!(sectionIndex, layoutEnvironment)
    }
    return layout
  }
  
  // Reload data
  private func reloadData(in collectionView: UICollectionView, context: Context, animated: Bool = false)
  {
    let coordinator = context.coordinator
    coordinator.sectionLayoutProvider = self.sectionLayoutProvider
    
    guard let dataSource = context.coordinator.dataSource else { return }
    let rowsHash = rows.hashValue // TODO: Determine if we want to keep this as hash comparison
    if coordinator.rowsHash != rowsHash
    {
      dataSource.apply(snapshot(), animatingDifferences: animated)
      coordinator.isFocusable = true
      collectionView.setNeedsFocusUpdate()
      collectionView.updateFocusIfNeeded()
      coordinator.isFocusable = false
    }
    coordinator.rowsHash = rowsHash
  }
  
  // Create snapshot
  private func snapshot() -> NSDiffableDataSourceSnapshot<Section, Item>
  {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
    for row in rows
    {
      snapshot.appendSections([row.section])
      snapshot.appendItems(row.items, toSection: row.section)
    }
    return snapshot
  }
}

public struct CollectionRow<Section: Hashable, Item: Hashable>: Hashable
{
  let section: Section
  let items: [Item]
}

// Fixes frames so they are a consistent size.
extension UIHostingController
{
  convenience public init(rootView: Content, ignoreSafeArea: Bool)
  {
    self.init(rootView: rootView)
    
    if ignoreSafeArea
    {
      disableSafeArea()
    }
  }
  
  func disableSafeArea()
  {
    guard let viewClass = object_getClass(view) else { return }
    
    let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
    if let viewSubclass = NSClassFromString(viewSubclassName) {
      object_setClass(view, viewSubclass)
    } else
    {
      guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
      guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
      
      if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets))
      {
        let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
          return .zero
        }
        class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
      }
      
      objc_registerClassPair(viewSubclass)
      object_setClass(view, viewSubclass)
    }
  }
}

找到了解决此问题的超级简单方法。这会捕捉到项目并传递索引,就像旧的集合视图一样。

我向 ContentView 添加了 @State var selection: Int = 0,并向地图和结果视图添加了“selection”绑定。

然后我用这个替换了 Collection View Controller 部分:

TabView(selection: $selection)  {
  ForEach(Array(zip(results.indices, results)), id: \.0) { index, result in
    ResultCardView(place: result[0]).tag(index)
  }
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))

它完全符合我的要求,我花了五分钟时间来实施。 我在这里找到了解决方案:https://swiftwithmajid.com/2020/09/16/tabs-and-pages-in-swiftui/