Double-tapping the same tab item to scroll to the top is pretty much a given in most Tabbed iOS apps. It’s a handy feature that lets our users zip back to the top of a lengthy list or any scrollable content with just a tap. Implementing this behavior in SwiftUI involves using ScrollViewReader’s scrollTo
method and a couple of other neat tricks to make it work. Here’s the step-by-step guide on how to achieve it.
The Essentials - What You Need
The ScrollViewReader
is the main container view that we need to make this functionality happen. It provides us with a scroll proxy that allows us to scroll to a specific view within our scrollviews. Working in conjunction with ScrollView it enables us to perform programmatic scrolling, which is exactly what we want in this scenario.
To implement scroll-to-top behavior after our user re-selects the active tab item, we need these 4 things
- ScrollViewReader Component for it’s programmatic scrolling interface
- ScrollView Component for, well, duh, scrollable content.
- An Hashable ID to identify the Scrollable element we want to scroll to.
- Tracking TabItem Selections & Listening for Changes: Required to trigger the scroll-to-top action.
MainTabView Implementation
For this demo, we have a swiftUI app with two tabItems, one for emojis and the other for apples.
For our app’s MainTabView to work properly we need an enum to tag and keep tract of each tabItem and we need an ObservableObject
to track & publish updates when our selected Tab changes. We are calling the ObservableObject TabStateManager
but feel free to call it whatever you want, we just want it to be reactive and publish a bunch of changes via combine publishers.
Here’s what it looks like in code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI
import Combine
enum Tab {
case emojiTab
case applesTab
}
final class TabStateManager: ObservableObject {
@Published var scrollFirstTabToTop = false
@Published var scrollSecondTabToTop = false
@Published var selectedTab: Tab = .emojiTab
private var cancellable: AnyCancellable?
init() {
listenForTabSelection()
}
deinit {
cancellable?.cancel()
cancellable = nil
}
/// When a new tab is selected, it checks if it matches the currently selected one.
/// If so, it toggles the appropriate flag (scrollFirstTabToTop or scrollSecondTabToTop) to enable scrolling to the top when the same tab is re-selected.
private func listenForTabSelection() {
cancellable = $selectedTab
.sink { [weak self] newTab in
guard let self = self else { return }
if newTab == self.selectedTab {
switch newTab {
case .emojiTab: self.scrollFirstTabToTop.toggle()
case .applesTab: self.scrollSecondTabToTop.toggle()
}
}
}
}
}
The TabStateManager manages tab selections by utilizing Combine to observe changes in the selectedTab. When a new tab is selected, it checks if it matches the currently selected one. If so, it toggles the appropriate flag (scrollFirstTabToTop or scrollSecondTabToTop) to enable scrolling to the top when the same tab is re-selected.
Here is what our MainTabView will look like
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct MainTabView: View {
@StateObject private var tabStateManager = TabStateManager()
var body: some View {
TabView(selection: $tabStateManager.selectedTab) {
EmojiCollectionView(scrollToTop: $tabStateManager.scrollFirstTabToTop)
.tag(Tab.emojiTab)
.tabItem {
Label("Cute Emojis", systemImage: "heart.fill")
}
AppleListView(scrollToTop: $tabStateManager.scrollSecondTabToTop)
.tag(Tab.applesTab)
.tabItem {
Label("Just Boxes", systemImage: "cube.box.fill")
}
}
}
}
Here’s our MainTabView’s sub-components. Pretty straightforward.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
private struct EmojiCollectionView: View {
@Binding var scrollToTop: Bool
let firstItemId = "item0"
let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)
let cuteEmojis: [String] = ["🐶", "🐱", "🐭", "🐰", "🐼", "🐻", "🐨", "🐯", "🦁", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🐥", "🦆", "🦉", "🦄", "🐴", "🐝", "🦋", "🐞", "🐌", "🦀", "🦄", "🦊", "🐢", "🦕", "🐬", "🐳", "🐙", "🐠", "🦑", "🐚", "🐍", "🦎", "🐇", "🦜", "🦢", "🦩", "🐾", "🐾", "🦔", "🐥", "🌟", "💖", "❤️", "💫", "✨", "🌈", "🎈", "🎀", "🌼", "🌸", "🌺", "🌻", "🍄", "🌈", "🍀"]
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach(0..<cuteEmojis.count, id: \.self) { index in
emojiView(index: index, emoji: cuteEmojis[index])
.id("item\(index)")
}
}
}
.onChange(of: scrollToTop) { shouldScrollToTop in
withAnimation {
scrollProxy.scrollTo(firstItemId, anchor: .top)
}
}
}
}
private func emojiView(index: Int, emoji: String) -> some View {
ZStack {
Text("\(index)\n\(emoji)")
.font(.largeTitle).bold()
.multilineTextAlignment(.center)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.red.opacity(0.5))
.frame(height: 200)
.padding(.horizontal)
}
}
}
private struct AppleListView: View {
@Binding var scrollToTop: Bool
let firstItemId = "item0"
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(0..<12) { index in
ZStack {
Text("\(index)\n🍎")
.font(.largeTitle).bold()
.multilineTextAlignment(.center)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.indigo.opacity(0.5))
.frame(height: 120)
.padding(.horizontal)
.id("item\(index)")
}
}
}
.onChange(of: scrollToTop) { shouldScrollToTop in
withAnimation {
scrollProxy.scrollTo(firstItemId, anchor: .top)
}
}
}
}
}
A Closer Look: Let’s Dissect Things
Ok, let’s take a closer look at these EmojiCollectionView
and AppleListView
sub-components, which contain the specific implementation for the scroll-to-top functionality.
First we have a binding bool called scrollToTop
to track exactly what the name denotes.
Now the onChange
Modifiers inside these components are what we use to detect changes in scrollToTop, and then also to trigger the scrolling action when this variable changes. Notice i said when the variable changes and not necessarily when the scrollToTop
bool turns to true. Because we don’t actually care if it turns to true or false. We only care that it changes, this is important because if you guard against false your view will actually not scroll to top because if you already tapped scroll to top previously the value will already be true.
The scrollProxy’s scrollTo
method is called inside an animation block to nicely scroll to the top element using the ID supplied in firstItemId variable.
Closing Thoughts
Combining all of these elements gives us the expected behavior. TabStateManager
essentially switches the scrollToTop boolean upon re-selecting a tab, initiating the scrolling action within the corresponding ScrollViewReader.
I understand it might seem complex at first, but I encourage you to explore the source code – it’ll become clearer with hands-on experimentation.
I hope you found this explanation useful. Thanks for taking the time to read through it.
Complete Source File
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import SwiftUI
import Combine
enum Tab {
case emojiTab
case applesTab
}
final class TabStateManager: ObservableObject {
@Published var scrollFirstTabToTop = false
@Published var scrollSecondTabToTop = false
@Published var selectedTab: Tab = .emojiTab
private var cancellable: AnyCancellable?
init() {
listenForTabSelection()
}
deinit {
cancellable?.cancel()
cancellable = nil
}
private func listenForTabSelection() {
cancellable = $selectedTab
.sink { [weak self] newTab in
guard let self = self else { return }
if newTab == self.selectedTab {
switch newTab {
case .emojiTab: self.scrollFirstTabToTop.toggle()
case .applesTab: self.scrollSecondTabToTop.toggle()
}
}
}
}
}
struct MainTabView: View {
@StateObject private var tabStateManager = TabStateManager()
var body: some View {
TabView(selection: $tabStateManager.selectedTab) {
EmojiCollectionView(scrollToTop: $tabStateManager.scrollFirstTabToTop)
.tag(Tab.emojiTab)
.tabItem {
Label("Cute Emojis", systemImage: "heart.fill")
}
AppleListView(scrollToTop: $tabStateManager.scrollSecondTabToTop)
.tag(Tab.applesTab)
.tabItem {
Label("Just Boxes", systemImage: "cube.box.fill")
}
}
}
}
private struct EmojiCollectionView: View {
@Binding var scrollToTop: Bool
let firstItemId = "item0"
let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)
let cuteEmojis: [String] = ["🐶", "🐱", "🐭", "🐰", "🐼", "🐻", "🐨", "🐯", "🦁", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🐥", "🦆", "🦉", "🦄", "🐴", "🐝", "🦋", "🐞", "🐌", "🦀", "🦄", "🦊", "🐢", "🦕", "🐬", "🐳", "🐙", "🐠", "🦑", "🐚", "🐍", "🦎", "🐇", "🦜", "🦢", "🦩", "🐾", "🐾", "🦔", "🐥", "🌟", "💖", "❤️", "💫", "✨", "🌈", "🎈", "🎀", "🌼", "🌸", "🌺", "🌻", "🍄", "🌈", "🍀"]
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach(0..<cuteEmojis.count, id: \.self) { index in
emojiView(index: index, emoji: cuteEmojis[index])
.id("item\(index)")
}
}
}
.onChange(of: scrollToTop) { shouldScrollToTop in
withAnimation {
scrollProxy.scrollTo(firstItemId, anchor: .top)
}
}
}
}
private func emojiView(index: Int, emoji: String) -> some View {
ZStack {
Text("\(index)\n\(emoji)")
.font(.largeTitle).bold()
.multilineTextAlignment(.center)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.red.opacity(0.5))
.frame(height: 200)
.padding(.horizontal)
}
}
}
private struct AppleListView: View {
@Binding var scrollToTop: Bool
let firstItemId = "item0"
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(0..<12) { index in
ZStack {
Text("\(index)\n🍎")
.font(.largeTitle).bold()
.multilineTextAlignment(.center)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.indigo.opacity(0.5))
.frame(height: 120)
.padding(.horizontal)
.id("item\(index)")
}
}
}
.onChange(of: scrollToTop) { shouldScrollToTop in
withAnimation {
scrollProxy.scrollTo(firstItemId, anchor: .top)
}
}
}
}
}