Codementor Events

Auto ScrollView using SwiftUI and Combine framework

Published Nov 28, 2020
Auto ScrollView using SwiftUI and Combine framework

In this tutorial, we are going to create simple auto ScrollView, that is auto scrolling to bottom when new value is added to our array of values.
For this tutorial we are going to use SwiftUI and Combine framework. For simplicity, we are not going to use Firestore to listen to updates on our array of values (like chat that consists of list of messages).
Lets say we have our chat application, where we can send and receive messages. First thing that we need to do is to create our object/model that will contain our values.

For that, we will create Message struct like following:

struct Message : Identifiable {
//MARK: Attributes
var id: Int
var message: String
var message_timestamp: Int
//MARK: Init
init(id: Int, message: String, message_timestamp: Int){
  self.id = id
  self.message = message
  self.message_timestamp = message_timestamp
 }
}

First to mention, we need our Message struct to conform to Identifiable protocol. We use Identifiable protocol to provide identity to our message. Later we will use this unique property to update our scroll target to scroll to desired message with target id.
Our next step is to create our store that will hold our messages array and subject (PassthroughSubject) that will do the broadcasting of new messages that are added to our messages array. For this we will create our MessagesStore class that must conform to ObservableObject protocol.
MessagesStore is object with a publisher that emits the changed value of our messages array. Also we will append first dummy message to our array on init of our class.

class MessagesStore: ObservableObject {
  //MARK: Attributes
  var didChange = PassthroughSubject<MessagesStore, Never>()
  @Published var messages: [Message] = [] {
     didSet { self.didChange.send(self) }
  }
  //MARK: Init
  init(messages: [Message] = []) {
     self.messages = messages
     fetch()
  }
  //MARK: fetch
  func fetch() {
     let message = Message(id: 1, message: “First!”, message_timestamp:    Int(Date().timeIntervalSince1970))
    self.messages.append(message)
  }
}

Now when we have everything ready, lets start to create our View.
We will not go into details of our view design in terms of assets, custom views (resizable text field, custom shapes, incoming/outgoing message views) and extensions, so we can focus on tutorial subject. All code will be available on github, at the end of this tutorial.
Now, our ChatView will contain ScrollView. For this we are going to use ScrollViewReader with ScrollView and iterate over our messagesStore. For every item in our messagesStore messages, we are going to use custom view called OutgoingPlainMessageView and show some fields.
First important thing is to use .id as identity of the view. Identity of our view will be message id or can be any other unique integer value/property of our message.
Second important thins is that we are going to implement onChange closure. This closure is going to listen for value changing, and our value is going to be scrollTarget. By default scrollTarget is going to be last added message/item to our array of messages, so when new message is added we fire our closure and update scrollTarget to last message id.
Third, there might be some additional issues like showing/hiding our keyboard. We will also add another onChange closure and listen for keyboardHeight value changes.
Last thing is to add onReceive closure, that will add an action to perform when this ScrollView detects data emitted by the given publisher, in our case messages array.

This is the place where our scrollTarget is updating.

var scrollView : some View {
 ScrollView(.vertical) {
   ScrollViewReader { scrollView in
     ForEach(self.messagesStore.messages) { msg in
       VStack {
         OutgoingPlainMessageView() {
           Text(msg.message).padding(.all, 20)
             .foregroundColor(Color.textColorPrimary)
             .background(Color.colorPrimary)
         }.listRowBackground(Color.backgroundColorList)
         Spacer()
       }
       // 1. First important thing is to use .id 
       //as identity of the view
       .id(msg.id)
       .padding(.leading, 10).padding(.trailing, 10)
}
// 2. Second important thing is that we are going to implement //onChange closure for scrollTarget change, 
//and scroll to last message id
.onChange(of: scrollTarget) { target in
    withAnimation {
        scrollView.scrollTo(target, anchor: .bottom)
    }
}
// 3. Third important thing is that we are going to implement //onChange closure for keyboardHeight change, and scroll to same //scrollTarget to bottom.
.onChange(of: keyboardHeight){ target in
   if(nil != scrollTarget){
   withAnimation {
   scrollView.scrollTo(scrollTarget, anchor: .bottom)
   }
  }
}
//4. Last thing is to add onReceive clojure, that will add an action to perform when this ScrollView detects data emitted by the given publisher, in our case messages array.
// This is the place where our scrollTarget is updating.
.onReceive(self.messagesStore.$messages) { messages in
    scrollView.scrollTo(messages.last!.id, anchor: .bottom)
    self.scrollTarget = messages.last!.id
   }
  }
 }
}

Now, when everything is set up, we can create our view body like following.

//MARK: View body
var body: some View {
  ZStack {
    Color.backgroundColorList.edgesIgnoringSafeArea(.all)
      VStack {
        if (self.messagesStore.messages.first != nil) {
          scrollView.onTapGesture(perform: {
           UIApplication.shared.windows.first?
             .rootViewController?.view.endEditing(true)
         })
        }
      Divider()
      HStack(spacing: 10) {
      ResizableTF(observableText: self.observableText, 
                  height: self.$height)
      .frame(height: self.height < 120 ? self.height : 120)
      .padding(.horizontal)
      .background(RoundedRectangle(cornerRadius: 10)
      .stroke(Color.gray, lineWidth: 0.5)
      .background(Color.backgroundColorList))
       Button(action: {
             appendChatMessage()
       }) {
       Image(“ic_send”)
       .resizable()
       .frame(width: 40, height: 40, alignment: .center)
         }
       }
       .padding(.leading, 10).padding(.trailing, 10)
       .padding(.bottom, 20)
      }
      .onAppear(){
      
      NotificationCenter.default
      .addObserver(forName: UIResponder
      .keyboardDidShowNotification, object: nil, queue: .main) {
           (data) in let height = data.userInfo
           [UIResponder.keyboardFrameEndUserInfoKey] as! NSValue
      self.keyboardHeight = height.cgRectValue.height
}
NotificationCenter.default.addObserver(
               forName: UIResponder.keyboardDidHideNotification, 
               object: nil, queue: .main){ (_) in
        self.keyboardHeight = 0
     }
     }
     .background(Color.backgroundColorList)
     .foregroundColor(Color.textColorSecondary)
     .navigationViewStyle(StackNavigationViewStyle())
     .gesture(DragGesture().updating($dragOffset, body: { (value,
                                     state, transaction) in
     if(value.startLocation.x < 20 && value.translation.width > 100)
     {
        self.mode.wrappedValue.dismiss()
     }
   }))
  }
}

And viola! We have our auto scrolling scrollView that scrolls to our desired target.
Here is the source of whole example.

Conact me at:
begic_kenan@hotmail.com
info@redundantcoding.com
LinkedIn
Codementor

Discover and read more posts from Kenan Begić
get started