How to disable table view scroll in UITableViewCell class rather than in the UITableView view class?

Viewed 48

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

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

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() {}
}
Related