skip to Main Content

Consider you are having the table view with a separate cell class that will be registered for table view later.

Now, we know how to disable the table view scroll using the table view instance, like in the below line.

tableView.isScrollEnabled = true/false

But what if I require to show some coach marks on the cell class, And I need to lock the table view scroll until that coach marks disappear using cell rather than table view. Because for a cell class table view instance is inaccessible since cell is within table view, not the table view within cell.

I’ve achieved this by using Notifications and Observers. But Please let me know if this can be achieved in any other way.

2

Answers


  1. Is your target supporting iOS 13+? If so you can use Combine SDK. It will give you the same principle of notification and observers.

    You will need a viewModel conforms to ObservableObject, then you will use @Published property wrapper, lets us easily construct types that emit signals whenever some of their properties were changed.

    ViewModel.swift

    enum ViewState {
      case loading
      case loaded
      case showCoachMark
      case hideCoachMark
    }
    
    class ViewModel: ObservableObject {
        @Published var state: ViewState = .loading
        ..... 
    }
    

    ViewController.swift

    import Combine
    import UIKit
    
    class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    
          private let viewModel = ViewModel()
          private var cancellable: AnyCancellable?
          
          override func viewDidLoad(){
             cancellable = viewModel.$state.sink { value in
                 // call tableview.isScrollEnabled = true/false
                 // Please push this back to the main thread if the state has
                 // been fetched via a request call 
             }
          }
    }
    
    Login or Signup to reply.
  2. Here is simple trick that can answer your question:

    class YourTableViewCell: UITableViewCell {
        
        weak var tableView: UITableView? // declare a weak reference to your tableView that contains this cell
        
        func disableScroll() {
            tableView?.isScrollEnabled = false
        }
        
        func enableScroll() {
            tableView?.isScrollEnabled = true
        }
    }
    
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let cell: YourTableViewCell = ...
        cell.tableView = tableView // assign this tableView to weak ref of tableView in YourTableViewCell
    }
    

    More isolation and loose-coupling:

    class YourTableViewCell: UITableViewCell {
        
        weak var onScrollEnabledChange: ((Bool) -> Void)?
        
        func disableScroll() {
            onScrollEnabledChange?(false)
        }
        
        func enableScroll() {
            onScrollEnabledChange?(true)
        }
    }
    
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let cell: YourTableViewCell = ...
        
        cell.onScrollEnabledChange = { isEnabled in
            // update above tableView's isScrollEnabled
            tableView.isScrollEnabled = isEnabled
        }
    }
    

    For a real solution:

    In your BookViewModel.swift

    import Foundation
    import Combine
    
    struct BookItemModel {
        let name: String
        var isDisplayingMark: Bool = true
    }
    
    enum DataLoadingState: Int {
        case new, loading, finish
    }
    
    struct BookViewModelInput {
        let loadData = PassthroughSubject<Void, Never>()
    }
    
    struct BookViewModelOutput {
        let dataLoadingState = PassthroughSubject<DataLoadingState,Never>()
    }
    
    protocol IBookViewModel {
        var input: BookViewModelInput { get }
        var output: BookViewModelOutput { get }
        func binding()
        
        func clickCoachMark(index: Int)
        func getItemCount() -> Int
        func getItemAt(index: Int) -> BookItemModel
    }
    
    class BookViewModel: IBookViewModel {
    
        private let _input = BookViewModelInput()
        var input: BookViewModelInput { return self._input }
        private let _output = BookViewModelOutput()
        var output: BookViewModelOutput { return self._output }
        
        private var cancellable = Set<AnyCancellable>()
        private lazy var defaultQueue: DispatchQueue = {
            let id = UUID().uuidString
            let queue = DispatchQueue(label: "BookViewModel.(id)", attributes: .concurrent)
            return queue
        }()
        
        private var _books: [BookItemModel] = []
        
        // MARK: - Function implementation
        func binding() {
            self._input.loadData
                .receive(on: self.defaultQueue)
                .sink {[unowned self] in
                    // this is event triggered from UI
                    // your screen own this BookViewModel, same life-cycle, so you can refer to self with unowned
                    
                    // call your async data fetching
                    self.fetchData()
                }.store(in: &self.cancellable)
        }
        
        func clickCoachMark(index: Int) {
            self._books[index].isDisplayingMark = false
            self._output.dataLoadingState.send(.finish) // trigger reloadData() again
        }
        
        // MARK: - Output handling
        func getItemCount() -> Int {
            return self._books.count
        }
        
        func getItemAt(index: Int) -> BookItemModel {
            return self._books[index]
        }
        
        // MARK: - Input handling
        private func fetchData() {
            self._output.dataLoadingState.send(.loading)
            
            // trigger block after 1 sec from now
            self.defaultQueue.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
                
                // Update data first
                self?._books = Array(1...5).map({ BookItemModel(name: "($0)") })
                // then trigger new state
                self?._output.dataLoadingState.send(.finish)
            }
        }
    }
    

    In your BookViewController.swift

    import UIKit
    import Combine
    
    // I mostly name it as ABCScreen: UIViewController
    class YourViewController: UIViewController {
        
        @IBOutlet weak var tableView: UITableView!
        
        private var cancellable = Set<AnyCancellable>()
        var viewModel: IBookViewModel!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            self.setupListView()
            
            // you can make a base class that trigger binding() on initialization then make BookViewModel become its subclass
            self.viewModel.binding()
            self.binding()
            
            self.viewModel.input.loadData.send() // Void data type here, you can pass nothing as argument.
        }
        
        private func setupListView() {
            self.tableView.register(UINib(nibName: "BookItemTableCell", bundle: nil), forCellReuseIdentifier: "BookItemTableCell")
            self.tableView.dataSource = self
            self.tableView.delegate = self
        }
        
        // here we are binding for output
        private func binding() {
            
            self.viewModel.output.dataLoadingState
                .sink {[weak self] newState in
                    guard let _self = self else { return } // unwrapping optional self
                    
                    // alway use weak self to refer to self in block that is triggered from viewModel
                    // because it can be async execution, different life-cycle with self (your screen)
                    
                    // Perform some big data updating here
                    
                    // This block is triggered from viewModel's defaultQueue
                    // it is not mainQueue to update UI
                    // Then switch to main queue to update UI,
                    DispatchQueue.main.async {
                        
                        _self.tableView.isScrollEnabled = true // reset on each reloadData()
                        _self.tableView.reloadData() // refresh to update UI by new data
    
                        switch newState {
                        case .finish: _self.[hideLoadingIndicator]
                        case .loading: _self.[showLoadingIndicator]
                        case .new: break // do nothing, new is just initial-value
                        }
                    }
                }.store(in: &self.cancellable)
        }
    }
    
    extension YourViewController: UITableViewDataSource, UITableViewDelegate {
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return self.viewModel.getItemCount()
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "BookItemTableCell", for: indexPath) as! BookItemTableCell
            let item = self.viewModel.getItemAt(index: indexPath.row)
            
            cell.setupUI(with: item)
            cell.onClickCoachMark = { [unowned self] in
                self.viewModel.clickCoachMark(index: indexPath.row)
            }
            
            return cell
        }
        
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            return 50
        }
        
        func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
            
            let item = self.viewModel.getItemAt(index: indexPath.row)
            // if there is atleast one book need to show coachMark -> disable scrolling
            if item.isDisplayingMark {
                tableView.isScrollEnabled = false
            }
        }
        
    }
    
    extension YourViewController {
        
        // You will init YourViewController like below:
        // let screen = YourViewController.build()
        // navigationController.push(screen)
        static func build() -> YourViewController {
            let view = YourViewController()
            view.viewModel = BookViewModel() // inject by setter
            return view
        }
    }
    

    In your BookItemTableCell.swift

    import UIKit
    
    class BookItemTableCell: UITableViewCell {
        
        var onClickCoachMark: (() -> Void)?
    
        override func awakeFromNib() {
            super.awakeFromNib()
        }
        
        // Button click handler
        func clickOnCoachMark() {
            self.hideCoachMark()
            self.onClickCoachMark?()
        }
        
        func setupUI(with item: BookItemModel) {
            if item.isDisplayingMark {
                self.showCoachMark()
            } else {
                self.hideCoachMark()
            }
        }
        
        private func hideCoachMark() {}
        private func showCoachMark() {}
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search