Search code examples
swiftuitableviewuibezierpathdrawrect

how to call draw(_ rect: CGRect) from cellForRowAt to draw on the custom tableview?


I currently have draw(_ rect: CGRect) working independent from the tableview (meaning, the xy points are all hardcoded). I am trying to put this draw method into my custom UITableview such that it will draw the individual charts in each of the cell.

In the custom tableview, there is one UIView in which I have associated w/ the "drawWorkoutChart" class

import UIKit

let ftp = 100

class drawWorkoutChart: UIView {
  
  
  override func draw(_ rect: CGRect) {
    
    let dataPointsX: [CGFloat] = [0,10,10,16,16,18]
    let dataPointsY: [CGFloat] = [0,55,73,73,52,52]
    
    func point(at ix: Int) -> CGPoint {
      
      let pointY = dataPointsY[ix]
      let x = (dataPointsX[ix] / dataPointsX.max()!) * rect.width
      let yMax = dataPointsY.max()! > 2*CGFloat(ftp) ? dataPointsY.max()! : 2*CGFloat(ftp)
      let y = (1 - ((pointY - dataPointsY.min()!) / (yMax - dataPointsY.min()!))) * rect.height
//    print("width:\(rect.width) height:\(rect.height) ix:\(ix) dataPoint:\(pointY) x:\(x) y:\(y) yMax:\(yMax)")
      return CGPoint(x: x, y: y)
    }
    
    func drawFtpLine() -> CGFloat {
      let yMax = dataPointsY.max()! > 2*CGFloat(ftp) ? dataPointsY.max()! : 2*CGFloat(ftp)
      let ftpY =  (CGFloat(ftp) / yMax ) * rect.height
      return ftpY
    }
    
    //Here's how you make your curve...
    let myBezier = UIBezierPath()
    myBezier.move(to: CGPoint(x: 0, y: (1 - dataPointsY[0]) * rect.height))
    
    for idx in dataPointsY.indices {
      myBezier.addLine(to: point(at: idx))
    }
//    UIColor.systemBlue.setFill()
//    myBezier.fill()
    
    UIColor.systemBlue.setStroke()
    myBezier.lineWidth = 3
    myBezier.stroke()
    
    let ftpLine = UIBezierPath()
    ftpLine.move(to: CGPoint(x: 0, y: drawFtpLine()))
    ftpLine.addLine(to: CGPoint(x: rect.width, y: drawFtpLine()))
    UIColor.systemYellow.setStroke()
    ftpLine.lineWidth = 2
    ftpLine.stroke()
  }
}

Once this is done, running the app will result in multiples of these charts drawn on the UIView

PIC: The UIVIew Chart that gets drawn on each cell in the tableview (Not enough reputation for it to show inline

The help I need (after scouring thru stack overflow / YouTube and webpages from YouTube) is that I still do not know how to call it from within cellForRowAt such that I can replace the dataPointX and dataPointY appropriately and have it draw each cell differently based on the input data.

currently the UITableViewCell has this:

extension VCLibrary: UITableViewDataSource{
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return jsonErgWorkouts.count
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    // this is cast AS! ErgWorkoutCell since this is a custom tableViewCell
    let cell = tableView.dequeueReusableCell(withIdentifier: "ergWorkoutCell") as? ErgWorkoutCell
    
    cell?.ergWorkoutTitle.text = jsonErgWorkouts[indexPath.row].title
    cell?.ergWorkoutTime.text = String(FormatDisplay.time(Int(jsonErgWorkouts[indexPath.row].durationMin * 60)))
    cell?.ergValue.text = String(jsonErgWorkouts[indexPath.row].value)
    cell?.ergWorkoutIF.text = String(jsonErgWorkouts[indexPath.row].intensity)
    
    return cell!
  }
}

Thanks.

----------- EDIT --- Update per @Robert C ----

UITableViewCell cellForRowAt

      func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // this is cast AS! ErgWorkoutCell since this is a custom tableViewCell
    let cell = tableView.dequeueReusableCell(withIdentifier: "ergWorkoutCell") as! ErgWorkoutCell

    cell.ergWorkoutTitle.text = jsonErgWorkouts[indexPath.row].title + "<><>" + String(jsonErgWorkouts[indexPath.row].id)

    var tempX: [CGFloat] = []
    var tempY: [CGFloat] = []
    
    jsonErgWorkouts[indexPath.row].intervals.forEach {
      tempX.append(CGFloat($0[0] * 60))
      tempY.append(CGFloat($0[1]))
    }

    // I used ergIndexPassed as a simple tracking to see which cell is updating
    cell.configure(ergX: tempX, ergY: tempY, ergIndexPassed: [indexPath.row])
//    cell.ergLineChartView.setNeedsDisplay() // <- This doesn't make a diff
  return cell
  }
}

The CustomTableCell

    import UIKit

extension UIView {
    func fill(with view: UIView) {
        addSubview(view)
        NSLayoutConstraint.activate([
            leftAnchor.constraint(equalTo: view.leftAnchor),
            rightAnchor.constraint(equalTo: view.rightAnchor),
            topAnchor.constraint(equalTo: view.topAnchor),
            bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
}




class ErgWorkoutCell: UITableViewCell {

  @IBOutlet weak var ergWorkoutTitle: UILabel!
  @IBOutlet weak var ergWorkoutTime: UILabel!
  @IBOutlet weak var ergWorkoutStress: UILabel!
  @IBOutlet weak var ergWorkoutIF: UILabel!
  @IBOutlet weak var ergLineChartView: UIView!


    static let identifier = String(describing: ErgWorkoutCell.self)

    // These code doesn't seem to Get triggered
    lazy var chartView: DrawAllErgWorkoutChart = {
        let chart = DrawAllErgWorkoutChart()
        chart.translatesAutoresizingMaskIntoConstraints = false
      chart.backgroundColor = .blue
        chart.clearsContextBeforeDrawing = true

        let height = chart.heightAnchor.constraint(equalToConstant: 500)
        height.priority = .defaultHigh
        height.isActive = true
        return chart
    }()

  // I changed this to also accept the ergIndex
  func configure(ergX: [CGFloat], ergY: [CGFloat], ergIndexPassed: [Int]) {    
      dataPointsX = ergX
      dataPointsY = ergY
      ergIndex = ergIndexPassed
    // This works. ergLineChartView is the Embedded UIView in the customCell
    ergLineChartView.setNeedsDisplay()

// These does nothing
//    let chart = DrawAllErgWorkoutChart()
//    chartView.setNeedsDisplay()
//    chart.setNeedsDisplay()

      print("ergWorkoutCell ergIndex:\(ergIndex)")//" DPY:\(dataPointsY)")
    }

  
  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    contentView.fill(with: chartView)
  }
  
  
//  https://stackoverflow.com/questions/38966565/fatal-error-initcoder-has-not-been-implemented-error-despite-being-implement
//  required init?(coder: NSCoder) {
//      fatalError("init(coder:) has not been implemented")
//  }

  required init?(coder aDecoder: NSCoder) {
     super.init(coder: aDecoder)
  }
}

Finally the draw(_ rect) code

import UIKit

// once I placed the dataPoints Array declaration here, then it works. This is a Global tho
var dataPointsX: [CGFloat] = []
var dataPointsY: [CGFloat] = []
var ergIndex: [Int] = []

class DrawAllErgWorkoutChart: UIView {
  private let ftp = 100
  
  override func draw(_ rect: CGRect) {
    super.draw(rect)
    
    let yMax = dataPointsY.max()! > 2*CGFloat(ftp) ? dataPointsY.max()! : 2*CGFloat(ftp)
    
    print("DrawAllErgWorkoutChart ergIndex:\(ergIndex) ") //time:\(dataPointsX) watt:\(dataPointsY)")
    
    func point(at ix: Int) -> CGPoint {
      
      let pointY = dataPointsY[ix]
      let x = (dataPointsX[ix] / dataPointsX.max()!) * rect.width
      let y = (1 - (pointY / yMax)) * rect.height
      return CGPoint(x: x, y: y)
    }
    
    func drawFtpLine() -> CGFloat {
      let ftpY =  (CGFloat(ftp) / yMax ) * rect.height
      return ftpY
    }
    
    //Here's how you make your curve...
    let myBezier = UIBezierPath()
    let startPt = dataPointsY[0]
    let nStartPt = (1 - (startPt / yMax)) * rect.height
    //    print("Cnt:\(ergLibCounter) StartPt:\(startPt) nStartPt:\(nStartPt)")
    //    myBezier.move(to: CGPoint(x: 0, y: (1 - dataPointsY[0]) * rect.height))
    myBezier.move(to: CGPoint(x: 0, y: nStartPt))
    
    for idx in dataPointsY.indices {
      myBezier.addLine(to: point(at: idx))
    }
    
    
    UIColor.systemBlue.setStroke()
    myBezier.lineWidth = 3
    myBezier.stroke()
    
    let ftpLine = UIBezierPath()
    ftpLine.move(to: CGPoint(x: 0, y: drawFtpLine()))
    ftpLine.addLine(to: CGPoint(x: rect.width, y: drawFtpLine()))
    UIColor.systemRed.setStroke()
    ftpLine.lineWidth = 2
    ftpLine.stroke()
    
  }
}

With the new Code above, what works:

  1. The IndexPath.row gets passed from cellForRowAt to the
    customTableCell view (ErgWorkoutCell)
  2. During initial launch, the print statements outputs these
 - ergWorkoutCell ergIndex:[1]
 - ergWorkoutCell ergIndex:[2]
 - ergWorkoutCell ergIndex:[3]
 - ergWorkoutCell ergIndex:[4]
 - ergWorkoutCell ergIndex:[5]
 - ergWorkoutCell ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
 - DrawAllErgWorkoutChart ergIndex:[6]
  1. for some reason, the tableview only gets the last indexPath.row passed to the draw(rect) function and all the charts are essentially just 1 chart, repeated

  2. Once I start scrolling, then the charts gets re-populated to the correct charts.

  3. Here is the entire project which may make it easier to see what's going on https://www.dropbox.com/s/3l7r7saqv0rhfem/uitableview-notupdating.zip?dl=0


Solution

  • You just need to make your data point arrays accessible as public properties:

    class DrawWorkoutChart: UIView {
    
        private let ftp = 100
    
        var dataPointsX: [CGFloat] = []
        var dataPointsY: [CGFloat] = []
    
        override func draw(_ rect: CGRect) {
            super.draw(rect)
        }
    }
    

    In your custom Cell class you need a custom method to pass that data points to your view:

    extension UIView {
        func fill(with view: UIView) {
            addSubview(view)
            NSLayoutConstraint.activate([
                leftAnchor.constraint(equalTo: view.leftAnchor),
                rightAnchor.constraint(equalTo: view.rightAnchor),
                topAnchor.constraint(equalTo: view.topAnchor),
                bottomAnchor.constraint(equalTo: view.bottomAnchor),
            ])
        }
    }
    
    class ErgWorkoutCell: UITableViewCell {
    
        static let identifier = String(describing: ErgWorkoutCell.self)
    
        lazy var chartView: DrawWorkoutChart = {
            let chart = DrawWorkoutChart()
            chart.translatesAutoresizingMaskIntoConstraints = false
            chart.backgroundColor = .black
            chart.clearsContextBeforeDrawing = true
    
            let height = chart.heightAnchor.constraint(equalToConstant: 500)
            height.priority = .defaultHigh
            height.isActive = true
            return chart
        }()
    
        func configure(dataPointsX: [CGFloat], dataPointsY: [CGFloat]) {
            chartView.dataPointsX = dataPointsX
            chartView.dataPointsY = dataPointsY
            chartView.setNeedsDisplay()
        }
    
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            contentView.fill(with: chartView)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    

    Notice that I'm explicitly setting the view's backgroundColor and clearsContextBeforeDrawing properties. This is important so that your your chart is cleared before draw(rect:) is called.

    The configure method is where the magic happens. We pass in our data points and call setNeedsDisplay so that the view is redrawn.

    Now in your cellForRowAt method you just need to pass in your data points:

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: ErgWorkoutCell.identifier) as! ErgWorkoutCell
        cell.configure(
            dataPointsX: .random(count: 6, in: 0..<20),
            dataPointsY: .random(count: 6, in: 0..<100)
        )
        return cell
    }
    

    And this is just a method for generating arrays with random values:

    extension Array where Element: SignedNumeric {
    
        static func random(count: Int, in range: Range<Int>) -> Self {
            return Array(repeating: 0, count: count)
                .map { _ in Int.random(in: range) }
                .compactMap { Element(exactly: $0) }
        }
    }