Swiftui - Scrollview starting position [duplicate] - swiftui

ScrollView(){
//no matter
}
Position is Top Left, but content will have smaller width than I needed
(2 strings in red rectangle must be single string, so need to enable both scrolls )
ScrollView([Axis.Set.horizontal, Axis.Set.vertical]) {
//no matter
}
In this case ScrollView's content have correct size for me.
But position of scrollView's content is centred ( both: horizontally and vertically )
How can I change default position to Top Left in case of ScrollView configured to both scrolls?

Update: Xcode 13.4 / macOS 12.4
The issue is still there, but now the solution is simpler using ScrollViewReader:
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollViewReader { sp in
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.border(Color.green)
.id("root")
}
.border(Color.gray)
.padding()
.onAppear {
sp.scrollTo("root", anchor: .topLeading)
}
}
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
Original
Here is possible approach. Tested with Xcode 11.2 / macOS 10.15.3
Demo:
Code (complete testable module, borders are added for better visibility of each component):
import SwiftUI
let test = """
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12118" systemVersion="16A323" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
"""
struct ScrollViewHelper: NSViewRepresentable {
func makeNSView(context: NSViewRepresentableContext<ScrollViewHelper>) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async { // << must be async, so view got into view hierarchy
view.enclosingScrollView?.contentView.scroll(to: .zero)
view.enclosingScrollView?.reflectScrolledClipView(view.enclosingScrollView!.contentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: NSViewRepresentableContext<ScrollViewHelper>) {
}
}
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.background(ScrollViewHelper()) // << active part !!
.border(Color.green) // uncomment to see border
}
.border(Color.gray)
.padding()
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
struct TestTwoAxisScrollView_Previews: PreviewProvider {
static var previews: some View {
TestTwoAxisScrollView()
}
}

Related

How to extend Navaigation View to show beyond safe area in swiftUI

I am trying to use NavigationView and created custom NavigationModifier:
but the navigation bar has some white space at top , i want to cover it all with navigation bar it self.
i tried adding ignoreSafeArea didn't got the result.
Here is my code:
struct HomeScreen: View {
var body: some View {
NavigationView {
VStack(spacing: 10) {
Text("Hello:Home")
.modifier(NavModifier())
}
}
}
}
Modifier:
struct NavModifier : ViewModifier {
init() {
let color = UIColor(Color.appTheme)
UINavigationBar.appearance().backgroundColor = color
}
func body(content: Content) -> some View {
return content
.navigationBarTitleDisplayMode(.inline)
.ignoresSafeArea(edges: .top)
.toolbar {
ToolbarItem(placement: .principal) {
Image(systemName: "person")
}
}
}
}
and result screen:
We need to replace appearance completely, like
struct NavModifier : ViewModifier {
init() {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.configureWithOpaqueBackground()
navBarAppearance.backgroundColor = UIColor.yellow // << for test !!
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
}
// ...
}
Tested with Xcode 13.4 / iOS 15.5

Additional safe area on NavigationView in SwiftUI

I am building a SwiftUI app where I have an overlay that is conditionally shown across my entire application like this:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.safeAreaInset(edge: .bottom) {
Group {
if myCondition {
EmptyView()
} else {
OverlayView()
}
}
}
}
}
}
I would expect this to adjust the safe area insets of the NavigationView and propagate it to any content view, so content is not stuck under the overlay. At least that's how additionalSafeAreaInsets in UIKit would behave. Unfortunately, it seems that SwiftUI ignores any safeAreaInsets() on a NavigationView (the overlay will show up, but safe area is not adjusted).
While I can use a GeometryReader to read the overlay size and then set safeAreaInsets() on ContentView, this will only work for ContentView - as soon as I navigate to the next view the safe area is gone.
Is there any nice way to get NavigationView to accept additional safe area insets, either by using safeAreaInsets() or by some other way?
So it seems NavigationView does not adjust its safe area inset when using .safeAreaInset. If this is intended or a bug is not clear to me. Anyway, I solved this for now like this (I wanted to use pure SwiftUI, using UIKit's additionalSafeAreaInsets might be an option to):
Main App File:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.environmentObject(SafeAreaController.shared)
.safeAreaInset(edge: .bottom) {
OverlayView()
.frameReader(safeAreaController.updateAdditionalSafeArea)
}
}
}
}
class SafeAreaController: ObservableObject {
static let shared = SafeAreaController()
#Published private(set) var additionalSafeArea: CGRect = .zero
func updateAdditionalSafeArea(_ newValue: CGRect) {
if newValue != additionalSafeArea {
additionalSafeArea = newValue
}
}
}
struct FrameReader: ViewModifier {
let changeChandler: ((CGRect) -> Void)
init(_ changeChandler: #escaping (CGRect) -> Void) {
self.changeChandler = changeChandler
}
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let newFrame = geometry.frame(in: .global)
changeChandler(newFrame)
}
return Color.clear
}
)
}
}
extension View {
func frameReader(_ changeHandler: #escaping (CGRect) -> Void) -> some View {
return modifier(FrameReader(changeHandler))
}
}
EVERY Content View that is pushed on your NavigationView:
struct ContentView: View {
#EnvironmentObject var safeAreaController: SafeAreaController
var body: some View {
YourContent()
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: safeAreaController.additionalSafeArea.height)
}
}
Why does it work?
In the main app file, a GeometryReader is used to read the size of the overlay created inside safeAreaInset(). The size is written to the shared SafeAreaController
The shared SafeAreaController is handed as an EnvironmentObject to every content view of our navigation
An invisible object is created as the .safeAreaInset of every content view with the height read from the SafeAreaController - this will basically create an invisible bottom safe area that is the same size as our overlay, thus making room for the overlay
struct ContentView: View {
var body: some View {
NavigationView {
ListView()
.navigationBarTitle("Test")
}
.safeAreaInset(edge: .bottom) {
HStack {
Spacer()
Text("My Overlay")
.padding()
Spacer()
}
.background(.ultraThinMaterial)
}
}
}
struct ListView: View {
var body: some View {
List(0..<30) { item in
NavigationLink {
Text("Next view")
} label: {
Text("Item \(item)")
}
}
}
}

Why does this SwiftUI LazyHStack update continuously?

I have a large set of URLs to images. I display the files' thumbnails in a LazyVStack. I have wrapped up the 'ThumbnailView' and the 'ThumbnailGenerator' in a struct and class respectively. However, when I ran the code I discovered that it kept re-initaiting the ThumbnailGenerators. After some investigation I found that after removing an HStack in the main view's hierarchy the problem went away.
Any thoughts as to why this might happen. (BTW I did log this with Apple, but still feel I am doing something wrong here myself.)
I have stripped the code back to the bare essentials here, replacing the thumbnail generation code with a simple sleep statement, to demonstrate the bug in action. Run it with the HStack in and it will print out the date continuously. Take it out and it works as expected.
#main
struct ExperimentApp: App {
var body: some Scene {
WindowGroup {
LazyVIssue()
.frame(width: 200, height: 140)
.padding(100)
}
}
}
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
HStack { /// <---- REMOVE THIS HSTACK AND IT WORKS
ThumbnailView()
Text("Filename \(i)")
}.padding()
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
init() {
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
thumbnailGenerator.image
}
}
final class ThumbnailGenerator: ObservableObject {
var image : Image
init() {
print("Initiating", Date())
image = Image(systemName: "questionmark.circle.fill")
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
guard let self = self else { return }
sleep(1) /// Simulate some work to fetch image
self.image = Image(systemName: "camera.circle.fill")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
I'm not sure why this is happening but I've seen had some funky things happen like this as well. If you initialize the ThumbnailGenerator() outside of the ThumbnailView init, I believe the issue goes away.
init(generator: ThumbnailGenerator) {
_thumbnailGenerator = StateObject(wrappedValue: generator)
}
Well, it is not clear for now what's going on here definitely (it is something about LazyVStack caching), but there is workaround - move everything into single row view.
Tested with Xcode 12.1 / iOS 14.1
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
ThumbnailView(i) // << single row view !!
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
let row: Int
init(_ row: Int) {
self.row = row
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
HStack {
thumbnailGenerator.image
Text("Filename \(row)")
}.padding()
}
}

Programatically scroll to SwiftUI list position? [duplicate]

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)

Get onAppear behaviour from list in ScrollView in SwiftUI

When creating a List view onAppear triggers for elements in that list the way you would expect: As soon as you scroll to that element the onAppear triggers. However, I'm trying to implement a horizontal list like this
ScrollView(.horizontal) {
HStack(spacing: mySpacing) {
ForEach(items) { item in
MyView(item: item)
.onAppear { \\do something }
}
}
}
Using this method the onAppear triggers for all items at once, that is to say: immediately, but I want the same behavior as for a List view. How would I go about doing this? Is there a manual way to trigger onAppear, or control when views load?
Why I want to achieve this: I have made a custom Image view that loads an image from an URL only when it appears (and substitutes a placeholder in the mean time), this works fine for a List view, but I'd like it to also work for my horizontal 'list'.
As per SwiftUI 2.0 (XCode 12 beta 1) this is finally natively solved:
In a LazyHStack (or any other grid or stack with the Lazy prefix) elements will only initialise (and therefore trigger onAppear) when they appear on screen.
Here is possible approach how to do this (tested/worked with Xcode 11.2 / iOS 13.2)
Demo: (just show dynamically first & last visible cell in scrollview)
A couple of important View extensions
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
extension View {
func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: #escaping (CGRect) -> Void) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let frame = geometry.frame(in: space)
if frame.intersects(rect) {
execute(frame)
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
And a demo view of how to use them with cell views being in scroll view
struct TestScrollViewOnVisible: View {
#State private var firstVisible: Int = 0
#State private var lastVisible: Int = 0
#State private var visibleRect: CGRect = .zero
var body: some View {
VStack {
HStack {
Text("<< \(firstVisible)")
Spacer()
Text("\(lastVisible) >> ")
}
Divider()
band()
}
}
func band() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<50) { i in
self.cell(for: i)
.ifVisible(in: self.visibleRect, in: .named("my")) { rect in
print(">> become visible [\(i)]")
// do anything needed with visible rects, below is simple example
// (w/o taking into account spacing)
if rect.minX <= self.visibleRect.minX && self.firstVisible != i {
DispatchQueue.main.async {
self.firstVisible = i
}
} else
if rect.maxX >= self.visibleRect.maxX && self.lastVisible != i {
DispatchQueue.main.async {
self.lastVisible = i
}
}
}
}
}
}
.coordinateSpace(name: "my")
.rectReader(self.$visibleRect, in: .named("my"))
}
func cell(for idx: Int) -> some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.yellow)
.frame(width: 80, height: 60)
.overlay(Text("\(idx)"))
}
}
I believe what you want to achieve can be done with LazyHStack.
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { value in
Text("Row \(value)")
.onAppear {
// Write your code for onAppear here.
}
}
}
}