Implementing Reverse Scrolling in SwiftUI

Ratnesh Jain

Jan 08, 2021 | 7 min read

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.

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.