January 8, 2021

Implementing Reverse Scrolling in SwiftUI

About Us

Ratnesh Jain

SwiftUI makes designing user-interface elements like a breeze. It makes it so easy that we can just think about the features without worrying about the details.

Table of Contents

In this post, we will look at how we can build a horizontal/vertical scroll list that will start from the opposite edge. For horizontal axis items should start from the right trailing edge and from bottom edge in vertical axis.
struct ReversedScrollView: View { var body: some View { } }
Here we define our view struct, in which we will implement the scrollview as below.
struct ReversedScrollView: View { var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { } .background(Color.gray.opacity(0.3)) } } }
In above code we have used the
GeometryReader
so that our scrollView can take the available size.
Then we are declaring our
ScrollView
with horizontal scroll behaviour. Now we will need some content to show in scrollView.
struct ReversedScrollView<Content: View>: View { var content: Content init(@ViewBuilder builder: ()->Content) { self.content = builder() } var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { content } .background(Color.gray.opacity(0.3)) } } }
struct ReversedScrollView_Previews: PreviewProvider { static var previews: some View { ReversedScrollView { Text("Hey scrollview") } } }
In above code, we have the normal scroll view wrapper view. as you can see the scrollview shrunked to its content's size as per the SwiftUI's layout system.
Now we can start work to have the reversed/backward scrolling behaviour.
struct ReversedScrollView<Content: View>: View { var content: Content init(@ViewBuilder builder: ()->Content) { self.content = builder() } var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { HStack { Spacer() content } .background(Color.gray.opacity(0.2)) } .background(Color.gray.opacity(0.3)) } } }
In this we embed our
content
to
HStack
with a
Spacer()
so that we can have spacing on the left side of the content.
If we now build and run then it will not do as we expected. This is because HStack will layout it according to its children.
struct ReversedScrollView_Previews: PreviewProvider { static var previews: some View { ReversedScrollView { ForEach(0..<3) { _ in Text("Hey scrollview") } } } }
ReversedScrollView { ForEach(0..<5) { _ in Text("Hey scrollview") } }
As you can see the content is laying out from the leading. which is not opposite of what we are trying to achieve.
struct ReversedScrollView<Content: View>: View { var content: Content init(@ViewBuilder builder: ()->Content) { self.content = builder() } var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { HStack { Spacer() content } .frame(minWidth: proxy.size.width) .background(Color.gray.opacity(0.2)) } .background(Color.gray.opacity(0.3)) } } }
struct ReversedScrollView_Previews: PreviewProvider { static var previews: some View { ReversedScrollView { ForEach(0..<5) { item in Text("\(item)") .padding() .background(Color.gray.opacity(0.5)) .cornerRadius(6) } } } }
By constraining
HStack
for the minimum width of the GeometryProxy's
width
, our
HStack
will now fit the whole available width. That will allow the spacer to send the content to the opposite edge.
Since we are setting the
minimumWidth
, it will expand the width as the content grows.
That's it. We have it. A
scrollView
with reversed scrolling.
Screen-Recording-2020-12-11-at-4.32.19-PM
Changing axis
Now we have implemented for horizontal axis. Lets go to support the both axis.
struct ReversedScrollView<Content: View>: View { var axis: Axis.Set var content: Content init(_ axis: Axis.Set = .horizontal, @ViewBuilder builder: ()->Content) { self.axis = axis self.content = builder() } var body: some View { GeometryReader { proxy in ScrollView(axis) { HStack { Spacer() content } .frame(minWidth: proxy.size.width) } } } }
So In above code we added the
axis
variable and initialised with the default `.horizontal` axis and updated our body property according to it. Now we need to update the
HStack
to respect the requested axis.
So we can create new container view `Stack` which will take axis argument and return the appropriate Stack.
struct Stack<Content: View>: View { var axis: Axis.Set var content: Content init(_ axis: Axis.Set = .vertical, @ViewBuilder builder: ()->Content) { self.axis = axis self.content = builder() } var body: some View { switch axis { case .horizontal: HStack { content } case .vertical: VStack { content } default: VStack { content } } } }
Here we are only supporting horizontal and vertical so we are sticking to VStack in default case.
Now updating our scrollview implementation.
struct ReversedScrollView<Content: View>: View { var axis: Axis.Set var content: Content init(_ axis: Axis.Set = .horizontal, @ViewBuilder builder: ()->Content) { self.axis = axis self.content = builder() } var body: some View { GeometryReader { proxy in ScrollView(axis) { Stack(axis) { Spacer() content } .frame(minWidth: proxy.size.width) } } } }
Here we can see that it started from the top, but we want to start from the bottom. This is becuase of
frame(minWidth:)
modifier we set for horizontal case. So we need to update that also.
What we want is to have
minWidth
in case of horizontal axis and
minHeight
in case of vertical axis. so in either of other value can be
nil
in the frame modifier. So we add two functions like below.
func minWidth(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? { axis.contains(.horizontal) ? proxy.size.width : nil } func minHeight(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? { axis.contains(.vertical) ? proxy.size.height : nil }
var body: some View { GeometryReader { proxy in ScrollView(axis) { Stack(axis) { Spacer() content } .frame( minWidth: minWidth(in: proxy, for: axis), minHeight: minHeight(in: proxy, for: axis) ) } }
Updating the preview
struct ReversedScrollView_Previews: PreviewProvider { static var previews: some View { ReversedScrollView(.vertical) { ForEach(0..<5) { item in Text("\(item)") .padding() .background(Color.gray.opacity(0.5)) .cornerRadius(6) } .frame(maxWidth: .infinity) } } }
Thats awesome. 🤩
The Leading space
Screen-Recording-2020-12-11-at-5.09.46-PM
Here we can see the first item starts at the very leading, we want to have some spacing there, so we can have some additional views like a add button.
We can do that by adding the `minLengh` to the our spacer like below.
var body: some View { GeometryReader { proxy in ScrollView(axis) { Stack(axis) { Spacer(minLength: leadingSpace) content } .frame( minWidth: minWidth(in: proxy, for: axis), minHeight: minHeight(in: proxy, for: axis) ) } } }
Updating the initialiser
struct ReversedScrollView<Content: View>: View { var axis: Axis.Set var leadingSpace: CGFloat var content: Content init(_ axis: Axis.Set = .horizontal, leadingSpace: CGFloat = 10, @ViewBuilder builder: ()->Content) { self.axis = axis self.leadingSpace = leadingSpace self.content = builder() }
And the preview
struct ReversedScrollView_Previews: PreviewProvider { static var previews: some View { ReversedScrollView(.horizontal, leadingSpace: 50) { ForEach(0..<12) { item in Text("\(item)") .padding() .background(Color.gray.opacity(0.5)) .cornerRadius(6) } } } }
Screen-Recording-2020-12-11-at-5.15.27-PM
Use cases
1. Emoji Collector
struct EmojiCollector: View { @State private var emojies: [String] = [] var body: some View { ZStack { ReversedScrollView(.horizontal, leadingSpace: 100) { ForEach(emojies.reversed(), id: \.self) { emoji in Text(emoji) .font(.largeTitle) .padding() .background(Color.green.opacity(0.5)) .cornerRadius(8) .transition(.move(edge: .bottom)) .frame(maxHeight: .infinity) } } HStack { Button(action: addEmoji) { ZStack { Circle() .fill(Color.green) .shadow(color: Color.green, radius: 4, x: 0, y: 0) Image(systemName: "plus") .font(.largeTitle) .foregroundColor(.white) } } .buttonStyle(PlainButtonStyle()) .frame(width: 60, height: 60) Spacer() } .padding() } .frame(height: 150) } func addEmoji() { withAnimation(.default) { if let random = String.emojies.randomElement() { self.emojies.append(random) } } } } extension String { static var emojies: [String] { "😀,😃,😄,😁,😆,😅,😂,🤣,☺️,😊,😇,🙂,🙃,😉,😌,😍,🥰,😘,😗,😙,😚,😋,😛,😝,😜,🤪,🤨,🧐,🤓,😎,🤩,🥳,😏,😒,😞,😔,😟,😕,🙁,☹️,😣,😖,😫,😩,🥺,😢,😭,😤,😠,😡,🤬,🤯,😳,🥵,🥶,😱,😨,😰,😥,😓,🤗,🤔,🤭,🤫,🤥,😶,😐,😑,😬,🙄,😯,😦,😧,😮,😲,🥱,😴,🤤,😪,😵,🤐,🥴,🤢,🤮,🤧,😷,🤒,🤕,🤑" .split(separator: ",") .compactMap({"\($0)"}) } }
Screen-Recording-2020-12-11-at-5.38.13-PM
2. Chat Screen
struct ChatView: View { @State private var messages: [Message] = [] @State private var text: String = "" @State private var targetMessage: Message? var body: some View { NavigationView { VStack(spacing: 0) { ScrollViewReader { scrollView in ReversedScrollView(.vertical, showsIndicators: false) { ForEach(messages) { message in MessageView(message: message) .transition(.move(edge: .bottom)) } } .padding(.horizontal) .onChange(of: targetMessage) { message in if let message = message { targetMessage = nil withAnimation(.default) { scrollView.scrollTo(message.id) } } } HStack { TextField("Message", text: $text) .frame(height: 44) .textFieldStyle(RoundedBorderTextFieldStyle()) Button(action: { send() }) { Text("Send") } .buttonStyle(AppButtonStyle()) } .padding(.horizontal) } } .navigationBarTitle("Chat") } } func send() { guard text.hasText else { return } let message = Message(id: UUID(), text: self.text, userId: "1", type: .random) self.messages.append(message) self.text = "" self.targetMessage = message } } struct ChatView_Previews: PreviewProvider { static var previews: some View { ChatView() } } extension Date { static var formatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "hh:mm" return formatter }() } // MARK: - Data enum RecieptType: Int, Codable, Equatable { case sent case received } struct Message: Codable, Hashable, Identifiable { var id: UUID var text: String var userId: String var type: RecieptType var date: Date = Date() } extension RecieptType { var backgroundColor: Color { switch self { case .sent: return .green case .received: return Color(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)) } } var textColor: Color { switch self { case .sent: return .white case .received: return .black } } static var random: RecieptType { let random = Int.random(in: 0...1) return RecieptType(rawValue: random) ?? .sent } } // MARK: - MessageView struct MessageView: View { var message: Message var body: some View { HStack { if message.type == .sent { Spacer(minLength: 16) } VStack(alignment: .trailing, spacing: 0) { Text(message.text) .padding(8) Text("\(message.date, formatter: Date.formatter)") .font(.system(size: 13)) .padding(6) } .background(message.type.backgroundColor) .foregroundColor(message.type.textColor) .cornerRadius(8) if message.type == .received { Spacer(minLength: 16) } } .frame(maxWidth: .infinity) .id(message.id) } }
Screen-Recording-2020-12-11-at-7.13.01-PM
Thanks for the reading.

Found this blog useful? Don't forget to share it with your network

Featured Insights

Team up with us to enhance and

achieve your business objectives

LET'SWORK

TLogoGETHER