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