我试图有一个滚动的水平项目列表,其中每个项目几乎占据整个屏幕宽度(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 的博客文章。
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)
}
}
}