0

I am trying to have a scrolling horizontal list of items, where each item takes up almost the full screen width (UIScreen.main.bounds.width - 50). There should be just enough of the next item visible for the user to know there is something to scroll to. I'd like to be able to determine the index of the item that's currently taking up most of the view.

The main view has three subviews: a search bar, a map, and a results view (which is where I want the scrolling horizontal list). The pins on the map need to update based on the currently displayed result.

I have included all code from the project for clarity and reproducibility.

The main view:

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()
      }
    }
}

Search Bar:

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
    }
  }
}

Map:

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 )
    }
  }
}

The results view:

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))
        }
    }
  }
}

Result Card View

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)
    }
}

Model

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)
  }
}

Mock Data

// 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]

Here is the CollectionView converted with UIViewRepresentable. This was based on a blog post by Samuel Defago.

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)
    }
  }
}
Crystal
  • 26
  • 6

1 Answers1

0

Found a super easy solution to this problem. This does the snap to item and passes an index, just like an old collection view would.

I added an @State var selection: Int = 0 to the ContentView, and "selection" Bindings to the map and results view.

Then I replaced the Collection View Controller section with this:

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

It does exactly what I want, and took me five minutes to implement. I found that solution here: https://swiftwithmajid.com/2020/09/16/tabs-and-pages-in-swiftui/

Crystal
  • 26
  • 6