{"id":9741,"date":"2023-01-30T14:50:00","date_gmt":"2023-01-30T11:50:00","guid":{"rendered":"https:\/\/handoli.com\/index.php\/2023\/01\/30\/observing-the-content-offset-of-a-swiftui-scrollview\/"},"modified":"2023-01-30T14:50:00","modified_gmt":"2023-01-30T11:50:00","slug":"observing-the-content-offset-of-a-swiftui-scrollview","status":"publish","type":"post","link":"https:\/\/handoli.com\/index.php\/2023\/01\/30\/observing-the-content-offset-of-a-swiftui-scrollview\/","title":{"rendered":"Observing the content offset of a SwiftUI ScrollView"},"content":{"rendered":"<p>When building various kinds of scrollable UIs, it\u2019s very common to want to observe the current scroll position (or <em>content offset<\/em>, as <code>UIScrollView<\/code> calls it) in order to trigger layout changes, load additional data when needed, or to perform other kinds of actions depending on what content that the user is currently viewing.<\/p>\n<p>However, when it comes to SwiftUI\u2019s <code>ScrollView<\/code>, there\u2019s currently (at the time of writing) no built-in way to perform such scrolling observations. While embedding a <code>ScrollViewReader<\/code> within a scroll view does enable us to <em>change<\/em> the scroll position in code, it strangely (especially given its name) doesn\u2019t let us <em>read<\/em> the current content offset in any way.<\/p>\n<p>One way to solve that problem would be to utilize the rich capabilities of UIKit\u2019s <code>UIScrollView<\/code>, which \u2014 thanks to its delegate protocol and the <code>scrollViewDidScroll<\/code> method \u2014 provides an easy way to get notified whenever any kind of scrolling occurred. However, even though I\u2019m normally a big fan of using <code>UIViewRepresentable<\/code> and the other <a href=\"https:\/\/www.swiftbysundell.com\/articles\/swiftui-and-uikit-interoperability-part-1\">SwiftUI\/UIKit interoperability mechanisms<\/a>, in this case, we\u2019d have to write quite a bit of extra code to bridge the gap between the two frameworks.<\/p>\n<p>That\u2019s mainly because \u2014 at least on iOS \u2014 we can only embed SwiftUI content within a <code>UIHostingController<\/code>, not within a self-managed <code>UIView<\/code>. So if we wanted to build a custom, observable version of <code>ScrollView<\/code> using <code>UIScrollView<\/code>, then we\u2019d have to wrap that implementation in a view controller, and then manage the relationship between our <code>UIHostingController<\/code> and things like the keyboard, the scroll view\u2019s content size, safe area insets, and so on. Not impossibly by any means, but still, a fair bit of additional work and complexity.<\/p>\n<p>So, let\u2019s instead see if we can find a completely SwiftUI-native way to perform such content offset observations.<\/p>\n<h2>Resolving frames using GeometryReader<\/h2>\n<p>One thing that\u2019s key to realize before we begin is that both <code>UIScrollView<\/code> and SwiftUI\u2019s <code>ScrollView<\/code> perform their scrolling by offsetting a container that\u2019s hosting our actual scrollable content. They then clip that container to their bounds to produce the illusion of the viewport moving. So if we can find a way to observe the <em>frame of that container<\/em>, then we\u2019ll essentially have found a way to observe the scroll view\u2019s content offset.<\/p>\n<p>That\u2019s where our good old friend <code>GeometryReader<\/code> comes in (wouldn\u2019t be a proper SwiftUI layout workaround without it, right?). While <code>GeometryReader<\/code> is mostly used to access the <code>size<\/code> of the view that it\u2019s hosted in (or, more accurately, that view\u2019s <em>proposed size<\/em>), it also has another neat trick up its sleeve \u2014 in that it can be asked to read the <code>frame<\/code> of the current view relative to a given coordinate system.<\/p>\n<p>To use that capability, let\u2019s start by creating a <code>PositionObservingView<\/code>, which lets us bind a <code>CGPoint<\/code> value to the current position of that view relative to a <code>CoordinateSpace<\/code> that we\u2019ll also pass in as an argument. Our new view will then embed a <code>GeometryReader<\/code> as a background (which will make that geometry reader take on the same size as the view itself) and will assign the resolved frame\u2019s <code>origin<\/code> as our offset using a preference key \u2014 like this:<\/p>\n<pre class=\"splash\"><code><span class=\"s-keyword\">struct<\/span> PositionObservingView&lt;Content: <span class=\"s-type\">View<\/span>&gt;: <span class=\"s-type\">View<\/span> {\n    <span class=\"s-keyword\">var<\/span> coordinateSpace: <span class=\"s-type\">CoordinateSpace<\/span>\n    <span class=\"s-keyword\">@Binding var<\/span> position: <span class=\"s-type\">CGPoint<\/span>\n    <span class=\"s-keyword\">@ViewBuilder var<\/span> content: () -&gt; <span class=\"s-type\">Content<\/span>\n\n    <span class=\"s-keyword\">var<\/span> body: <span class=\"s-keyword\">some<\/span> <span class=\"s-type\">View<\/span> {\n        <span class=\"s-call\">content<\/span>()\n            .<span class=\"s-call\">background<\/span>(<span class=\"s-type\">GeometryReader<\/span> { geometry <span class=\"s-keyword\">in<\/span>\n                <span class=\"s-type\">Color<\/span>.<span class=\"s-property\">clear<\/span>.<span class=\"s-call\">preference<\/span>(\n                    key: <span class=\"s-type\">PreferenceKey<\/span>.<span class=\"s-keyword\">self<\/span>,\n                    value: geometry.<span class=\"s-call\">frame<\/span>(in: coordinateSpace).<span class=\"s-property\">origin<\/span>\n                )\n            })\n            .<span class=\"s-call\">onPreferenceChange<\/span>(<span class=\"s-type\">PreferenceKey<\/span>.<span class=\"s-keyword\">self<\/span>) { position <span class=\"s-keyword\">in<\/span>\n                <span class=\"s-keyword\">self<\/span>.<span class=\"s-property\">position<\/span> = position\n            }\n    }\n}<\/code><\/pre>\n<p class=\"info\">To learn more about how the <code>@ViewBuilder<\/code> attribute can be used when building custom SwiftUI container views, <a href=\"https:\/\/www.swiftbysundell.com\/tips\/annotating-properties-with-result-builder-attributes\">check out this article<\/a>.<\/p>\n<p>The reason we use SwiftUI\u2019s preference system above is because our <code>GeometryReader<\/code> will be invoked as part of the view updating process, and we\u2019re not allowed to directly mutate our view\u2019s state during that process. So, by using a preference instead, we can deliver our <code>CGPoint<\/code> values to our view in an asynchronous fashion, which then lets us assign those values to our <code>position<\/code> binding.<\/p>\n<p>Now all that we need to do is to implement the <code>PreferenceKey<\/code> type that\u2019s used above, and we\u2019ll be good to go:<\/p>\n<pre class=\"splash\"><code><span class=\"s-keyword\">private extension<\/span> <span class=\"s-type\">PositionObservingView<\/span> {\n    <span class=\"s-keyword\">struct<\/span> PreferenceKey: <span class=\"s-type\">SwiftUI<\/span>.<span class=\"s-type\">PreferenceKey<\/span> {\n        <span class=\"s-keyword\">static var<\/span> defaultValue: <span class=\"s-type\">CGPoint<\/span> { .<span class=\"s-dotAccess\">zero<\/span> }\n\n        <span class=\"s-keyword\">static func<\/span> reduce(value: <span class=\"s-keyword\">inout<\/span> <span class=\"s-type\">CGPoint<\/span>, nextValue: () -&gt; <span class=\"s-type\">CGPoint<\/span>) {\n            <span class=\"s-comment\">\/\/ No-op<\/span>\n        }\n    }\n}<\/code><\/pre>\n<p class=\"info\">We don\u2019t actually need to implement any kind of <code>reduce<\/code> algorithm above, since we\u2019ll only have a single view delivering values using that preference key within any given hierarchy (since our implementation is entirely contained within our <code>PositionObservingView<\/code>).<\/p>\n<p>Alright, so now we have a view that\u2019s capable of reading and observing its own position within a given coordinate system. Let\u2019s now use that view to build a <code>ScrollView<\/code> wrapper that\u2019ll let us accomplish our original goal \u2014 to be able to read the current content offset within such a scroll view.<\/p>\n<h2>From position to content offset<\/h2>\n<p>Our new <code>ScrollView<\/code> wrapper will essentially have two responsibilities \u2014 one, it\u2019ll need to convert the position of our inner <code>PositionObservingView<\/code> into the current scroll position (or content offset), and two, it\u2019ll also need to define a <code>CoordinateSpace<\/code> that the inner view can use to resolve its position. Besides that, it\u2019ll simply forward its configuration parameters to its underlying <code>ScrollView<\/code>, so that we can decide what <code>axes<\/code> we want each scroll view to operate on, and so that we can decide whether or not to display any scrolling indicators.<\/p>\n<p>The good news is that converting our inner view\u2019s position into content offset is as easy as negating both the <code>x<\/code> and <code>y<\/code> components of those <code>CGPoint<\/code> values. That\u2019s because, as discussed earlier, a scroll view\u2019s content offset is essentially just the distance that the container has been moved relative to the scroll view\u2019s bounds.<\/p>\n<p>So let\u2019s go ahead and implement our custom scroll view, which we\u2019ll name <code>OffsetObservingScrollView<\/code> (spelling out <code>ContentOffset<\/code> does feel a bit too verbose in this case):<\/p>\n<pre class=\"splash\"><code><span class=\"s-keyword\">struct<\/span> OffsetObservingScrollView&lt;Content: <span class=\"s-type\">View<\/span>&gt;: <span class=\"s-type\">View<\/span> {\n    <span class=\"s-keyword\">var<\/span> axes: <span class=\"s-type\">Axis<\/span>.<span class=\"s-type\">Set<\/span> = [.<span class=\"s-dotAccess\">vertical<\/span>]\n    <span class=\"s-keyword\">var<\/span> showsIndicators = <span class=\"s-keyword\">true\n    @Binding var<\/span> offset: <span class=\"s-type\">CGPoint<\/span>\n    <span class=\"s-keyword\">@ViewBuilder var<\/span> content: () -&gt; <span class=\"s-type\">Content<\/span>\n\n    <span class=\"s-comment\">\/\/ The name of our coordinate space doesn't have to be\n    \/\/ stable between view updates (it just needs to be\n    \/\/ consistent within this view), so we'll simply use a\n    \/\/ plain UUID for it:<\/span>\n    <span class=\"s-keyword\">private let<\/span> coordinateSpaceName = <span class=\"s-type\">UUID<\/span>()\n\n    <span class=\"s-keyword\">var<\/span> body: <span class=\"s-keyword\">some<\/span> <span class=\"s-type\">View<\/span> {\n        <span class=\"s-type\">ScrollView<\/span>(axes, showsIndicators: showsIndicators) {\n            <span class=\"s-type\">PositionObservingView<\/span>(\n                coordinateSpace: .<span class=\"s-call\">named<\/span>(coordinateSpaceName),\n                position: <span class=\"s-type\">Binding<\/span>(\n                    get: { offset },\n                    set: { newOffset <span class=\"s-keyword\">in<\/span>\n                        offset = <span class=\"s-type\">CGPoint<\/span>(\n                            x: -newOffset.<span class=\"s-property\">x<\/span>,\n                            y: -newOffset.<span class=\"s-property\">y<\/span>\n                        )\n                    }\n                ),\n                content: content\n            )\n        }\n        .<span class=\"s-call\">coordinateSpace<\/span>(name: coordinateSpaceName)\n    }\n}<\/code><\/pre>\n<p class=\"info\">Note how we\u2019re able to create a completely custom <code>Binding<\/code> for our inner view\u2019s <code>position<\/code> parameter, by defining a getter and setter using closures. That\u2019s a great option in situations like the one above, when we want to transform a value before assigning it to another <code>Binding<\/code>.<\/p>\n<p>That\u2019s it! We now have a drop-in replacement for SwiftUI\u2019s built-in <code>ScrollView<\/code> which enables us to observe the current content offset \u2014 which we can then bind to any state property that we\u2019d like, for example in order to change the layout of a header view, to report analytics events to our server, or to perform any other kind of scroll position-based operation. You can find a complete example that uses the above <code>OffsetObservingScrollView<\/code> in order to implement a collapsable header view <a href=\"https:\/\/gist.github.com\/JohnSundell\/341f5855f4ede71a7741e99881c74daf\">right here<\/a>.<\/p>\n<p>I hope that you found this article useful. If you have any questions, comments, or feedback, then feel free to <a href=\"https:\/\/mastodon.social\/@johnsundell\">contact me on Mastodon<\/a>, or <a href=\"https:\/\/www.swiftbysundell.com\/contact\">send me an email<\/a>.<\/p>\n<p>Thanks for reading!<\/p>","protected":false},"excerpt":{"rendered":"<p>When building various kinds of scrollable UIs, it\u2019s very common to want to observe the current scroll position (or content offset, as UIScrollView calls it) in order to trigger layout changes, load additional data when needed, or to perform other kinds of actions depending on what content that the user is currently viewing. However, when [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rop_custom_images_group":[],"rop_custom_messages_group":[],"rop_publish_now":"initial","rop_publish_now_accounts":[],"rop_publish_now_history":[],"rop_publish_now_status":"pending","footnotes":""},"categories":[1,15],"tags":[],"class_list":["post-9741","post","type-post","status-publish","format-standard","hentry","category-explore","category-world"],"_links":{"self":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts\/9741","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/comments?post=9741"}],"version-history":[{"count":0,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/posts\/9741\/revisions"}],"wp:attachment":[{"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/media?parent=9741"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/categories?post=9741"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/handoli.com\/index.php\/wp-json\/wp\/v2\/tags?post=9741"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}