Home Double-Tap to Scroll-to-Top Functionality in SwiftUI TabBars
Post
Cancel

Double-Tap to Scroll-to-Top Functionality in SwiftUI TabBars

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)
                }
            }
        }
    }
}

This post is licensed under CC BY 4.0 by the author.