My task is to make a scrollview, which receives an array of strings and an overlay, which is located in the center. When some view gets into this overlay, it should immediately align to its borders, that is, be in the center of the screen. The list should start from the middle of the screen with the first element. I was looking for a solution here text
I have struct
struct Region: Equatable, Hashable {
let name: String
}
PreferenceKeyStruct
struct ViewOffsetKey: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
and subView
struct RegionView: View {
let name: String
var body: some View {
HStack {
Text(self.name)
.foregroundColor(.white)
.font(.system(.body))
Spacer()
}
.padding(16)
.frame(width: 348, alignment: .topLeading)
.background(Color.black)
.cornerRadius(50)
.id(name)
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: [name: $0.frame(in: .global)])
})
}
}
The main view is realized through CurrentValueSubject and AnyPublisher. I check if subView intersects a rectangle with a height of 1, then I write it as displayed. When the scroll ends, I call the scrollTo method
struct RegionListView: View {
let detector: CurrentValueSubject<[String: CGRect], Never>
let publisher: AnyPublisher<[String: CGRect], Never>
let regions: [Region] = [
Region(name: "Region 1"),
Region(name: "Region 2"),
Region(name: "Region 3"),
Region(name: "Region 4"),
Region(name: "Region 5"),
Region(name: "Region 6"),
Region(name: "Region 7"),
Region(name: "Region 8"),
Region(name: "Region 9"),
Region(name: "Region 10"),
Region(name: "Region 11"),
Region(name: "Region 12"),
Region(name: "Region 13"),
Region(name: "Region 14"),
Region(name: "Region 15"),
Region(name: "Region 16"),
Region(name: "Region 17"),
Region(name: "Region 18"),
Region(name: "Region 19"),
Region(name: "Region 20"),
]
@State private var topVisibleChildId: String?
init() {
let detector = CurrentValueSubject<[String: CGRect], Never>([:])
self.publisher = detector
.debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.detector = detector
}
var body: some View {
GeometryReader { geometryReader in
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 8) {
ForEach(self.regions, id: \.self) { region in
RegionView(name: region.name)
}
}
.frame(maxWidth: .infinity)
.onPreferenceChange(ViewOffsetKey.self) { childFrames in
detector.send(childFrames)
var visibleChildIds = [String]()
let screenMid = geometryReader.size.height / 2 + 56
for (id, childFrame) in childFrames where childFrame.intersects(CGRect(x: 0, y: Int(screenMid), width: .max, height: 56)) {
print("id \(id) childFrame \(childFrame)")
visibleChildIds.append(id)
}
visibleChildIds.sort()
if let first = visibleChildIds.first {
topVisibleChildId = first
}
}
}
.safeAreaPadding(.top, geometryReader.size.height / 2 - 28)
.safeAreaPadding(.bottom, geometryReader.size.height / 2 - 28)
.background(Color.black)
.onReceive(publisher) { _ in
proxy.scrollTo(topVisibleChildId, anchor: .center)
}
.overlay(
Text("Top Visible Child: \(topVisibleChildId ?? "")")
.padding()
.background(Color.blue.opacity(1))
.foregroundColor(.white)
.cornerRadius(10),
alignment: .top
)
.overlay(
Rectangle()
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.clear)
.border(.green, width: 4),
alignment: .center
)
}
}
}
}
My question: is it possible to implement a behavior in which changing cells will be similar to choosing a time when setting an alarm. That is, the cell will not have to be scrolled to the center if the user did not finish scrolling in the middle. I hope I explained it clearly.
It sounds like you want the scroll view to snap to a view when it finishes scrolling, then you want to be able to detect which of the views is under the overlay.
There are various modifiers that provide this functionality for free:
.scrollPosition
to track the current scroll position. This works together with .scrollTargetLayout
, which needs to be applied to the VStack
..scrollTargetBehavior(.viewAligned)
to snap to a view.In order that the scroll view is positioned with the first of the views positioned in the center when it first appears, add .contentMargins
to pad the scrolled content.
Btw, your example is using quite a lot of deprecated modifiers. You might want to consider the following replacements:
.foregroundColor
with foregroundStyle
..cornerRadius
with a clip shape, or fill the background in
a shape..overlay
modifiers that you were using were also deprecated.Here is the updated example to show it all working:
struct RegionView: View {
let name: String
var body: some View {
Text(name)
.foregroundStyle(.white)
.font(.body)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct RegionListView: View {
// let detector: CurrentValueSubject<[String: CGRect], Never>
// let publisher: AnyPublisher<[String: CGRect], Never>
let regions: [Region] = [
Region(name: "Region 1"),
Region(name: "Region 2"),
Region(name: "Region 3"),
Region(name: "Region 4"),
Region(name: "Region 5"),
Region(name: "Region 6"),
Region(name: "Region 7"),
Region(name: "Region 8"),
Region(name: "Region 9"),
Region(name: "Region 10"),
Region(name: "Region 11"),
Region(name: "Region 12"),
Region(name: "Region 13"),
Region(name: "Region 14"),
Region(name: "Region 15"),
Region(name: "Region 16"),
Region(name: "Region 17"),
Region(name: "Region 18"),
Region(name: "Region 19"),
Region(name: "Region 20"),
]
let overlayHeight: CGFloat = 56
@State private var topVisibleChildId: String?
var body: some View {
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
ForEach(regions, id: \.name) { region in
RegionView(name: region.name)
.frame(maxHeight: overlayHeight)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $topVisibleChildId, anchor: .top)
.scrollTargetBehavior(.viewAligned)
.contentMargins(.vertical, (proxy.size.height - overlayHeight) / 2)
.background(.black)
.overlay(alignment: .top) {
Text("Top Visible Child: \(topVisibleChildId ?? "")")
.padding()
.foregroundStyle(.white)
.background(.blue, in: .rect(cornerRadius: 10))
}
.overlay {
Rectangle()
.strokeBorder(.green, lineWidth: 4)
.frame(height: overlayHeight)
}
.onAppear {
topVisibleChildId = regions.first?.name
}
}
}
}