Search code examples
iosuicollectionviewcellcalayer

UICollectionViewCell redrawing custom UIView sublayers with delay


I want to display something similar to the history in the Activity app, but for the sake of this question it's a simple pie diagram instead of 3 rings. I created a custom UIView and use draw(in ctx:) to draw the pie.

The trouble is that when I scroll and cells get reused, the pie persists in those cells for a brief moment before being redrawn.

Here's how to reproduce this:

  1. Create a new single view project
  2. Copy paste the code below in ViewController.swift and Main.storyboard
  3. Build & Run
  4. Scroll down: you'll see a bunch of colored dots. Scroll some more and you should see the blinking dots.

Things you might ask:

  • It's a simplified "calendar" of 10 months with 30 days (cells), and only the 2nd month has dots to showcase the issue.
  • I add pieLayer as a sublayer of the UIView layer instead of using the layer directly because, in my project, I have more than just one custom layer

ViewController.swift

class ViewController: UICollectionViewController {
   override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 10
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 30
    }

   override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "DayCell", for: indexPath) as! RingCell
        let ring = cell.ring!
        ring.pieLayer.radius = 15
        ring.pieLayer.maxValue = 30
        if indexPath.section == 2 {
            ring.pieLayer.value = CGFloat(indexPath.row)
            ring.pieLayer.segmentColor = (indexPath.row % 2 == 0 ? UIColor.green.cgColor : UIColor.red.cgColor)
        } else {
            ring.pieLayer.value = 0
            ring.pieLayer.segmentColor = UIColor.clear.cgColor
        }
        ring.pieLayer.setNeedsDisplay()
        return cell
    }
}

class RingCell: UICollectionViewCell {
    @IBOutlet weak var ring: PieView!

    override func prepareForReuse() {
        super.prepareForReuse()
        ring.pieLayer.value = 0
        ring.pieLayer.segmentColor = UIColor.clear.cgColor
        ring.pieLayer.setNeedsDisplay()
    }
}

open class PieView: UIView {

    // MARK: Initializers

    public override init(frame: CGRect) {
        super.init(frame: frame)
        initLayers()
    }

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

    // MARK: Internal initializers

    var pieLayer: ProgressPieLayer!

    internal func initLayers() {
        pieLayer = ProgressPieLayer(centeredIn: layer.bounds)
        rasterizeToScale(pieLayer)
        layer.addSublayer(pieLayer)
        pieLayer.setNeedsDisplay()
    }

    private func rasterizeToScale(_ layer: CALayer) {
        layer.contentsScale = UIScreen.main.scale
        layer.shouldRasterize = true
        layer.rasterizationScale = UIScreen.main.scale * 2
    }
}

private extension CGFloat {
    var toRads: CGFloat { return self * CGFloat.pi / 180 }
}

internal class ProgressPieLayer: CAShapeLayer {
    @NSManaged var value: CGFloat
    @NSManaged var maxValue: CGFloat
    @NSManaged var radius: CGFloat
    @NSManaged var segmentColor: CGColor

    convenience init(centeredIn bounds: CGRect,
                     radius: CGFloat = 15,
                     color: CGColor = UIColor.clear.cgColor,
                     value: CGFloat = 100,
                     maxValue: CGFloat = 100) {
        self.init()
        self.bounds = bounds
        self.position = CGPoint(x: bounds.midX, y: bounds.midY)
        self.value = value
        self.maxValue = maxValue
        self.radius = radius
        self.segmentColor = color
    }

    override func draw(in ctx: CGContext) {
        super.draw(in: ctx)
        let shiftedStartAngle: CGFloat = -90 // start on top
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let angle = 360 / maxValue * value + shiftedStartAngle

        ctx.move(to: center)
        ctx.addArc(center: center,
                   radius: radius,
                   startAngle: shiftedStartAngle.toRads,
                   endAngle: angle.toRads,
                   clockwise: false)
        ctx.setFillColor(segmentColor)
        ctx.fillPath()
    }
}

Main.Storyboard

<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12120" systemVersion="16F73" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="NK3-ad-iUE">
    <device id="retina4_7" orientation="portrait">
        <adaptation id="fullscreen"/>
    </device>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12088"/>
        <capability name="Constraints to layout margins" minToolsVersion="6.0"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="NFp-0o-M02">
            <objects>
                <collectionViewController id="NK3-ad-iUE" customClass="ViewController" customModule="UICN" customModuleProvider="target" sceneMemberID="viewController">
                    <collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="Sy5-uf-jPK">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
                        <collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="0.0" id="fkD-3N-K4T">
                            <size key="itemSize" width="50" height="50"/>
                            <size key="headerReferenceSize" width="0.0" height="0.0"/>
                            <size key="footerReferenceSize" width="0.0" height="0.0"/>
                            <inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
                        </collectionViewFlowLayout>
                        <cells>
                            <collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="DayCell" id="CXc-tU-7nQ" customClass="RingCell" customModule="UICN" customModuleProvider="target">
                                <rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
                                    <rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
                                    <autoresizingMask key="autoresizingMask"/>
                                    <subviews>
                                        <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Ic6-ea-Qzy" userLabel="Pie" customClass="PieView" customModule="UICN" customModuleProvider="target">
                                            <rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
                                            <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
                                        </view>
                                    </subviews>
                                </view>
                                <constraints>
                                    <constraint firstAttribute="trailingMargin" secondItem="Ic6-ea-Qzy" secondAttribute="trailing" constant="-8" id="9fj-SE-D1e"/>
                                    <constraint firstItem="Ic6-ea-Qzy" firstAttribute="top" secondItem="CXc-tU-7nQ" secondAttribute="topMargin" constant="-8" id="Hnv-yr-EBN"/>
                                    <constraint firstItem="Ic6-ea-Qzy" firstAttribute="leading" secondItem="CXc-tU-7nQ" secondAttribute="leadingMargin" constant="-8" id="I4E-ZD-JZf"/>
                                    <constraint firstAttribute="bottomMargin" secondItem="Ic6-ea-Qzy" secondAttribute="bottom" constant="-8" id="XOW-ao-t0L"/>
                                </constraints>
                                <connections>
                                    <outlet property="ring" destination="Ic6-ea-Qzy" id="ZoZ-ok-TLK"/>
                                </connections>
                            </collectionViewCell>
                        </cells>
                        <connections>
                            <outlet property="dataSource" destination="NK3-ad-iUE" id="nAW-La-2EK"/>
                            <outlet property="delegate" destination="NK3-ad-iUE" id="YCh-0p-7gX"/>
                        </connections>
                    </collectionView>
                </collectionViewController>
                <placeholder placeholderIdentifier="IBFirstResponder" id="6r8-g7-Adg" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="-100" y="214.54272863568218"/>
        </scene>
    </scenes>
</document>

Edit

I might have found a solution, I created a drawPie method in ProgressPieLayer.

internal class ProgressPieLayer: CAShapeLayer {
    @NSManaged var value: CGFloat
    @NSManaged var maxValue: CGFloat
    @NSManaged var radius: CGFloat
    @NSManaged var segmentColor: CGColor

    convenience init(centeredIn bounds: CGRect,
                     radius: CGFloat = 15,
                     color: CGColor = UIColor.clear.cgColor,
                     value: CGFloat = 100,
                     maxValue: CGFloat = 100) {
        self.init()
        self.bounds = bounds
        self.position = CGPoint(x: bounds.midX, y: bounds.midY)
        self.value = value
        self.maxValue = maxValue
        self.radius = radius
        self.segmentColor = color
    }

    func drawPie() {
        let shiftedStartAngle: CGFloat = -90 // start on top
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let angle = 360 / maxValue * value + shiftedStartAngle
        let piePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: shiftedStartAngle.toRads, endAngle: angle.toRads, clockwise: false)
        piePath.addLine(to: center)
        self.path = piePath.cgPath
        self.fillColor = segmentColor
    }
}

I call

ring.pieLayer.drawPie()

In UICollectionViewCell#prepareForReuse and collectionView(_ collectionView:cellForItemAt:) and it works

I'm using UIBezierPath instead of CGContext, not quite sure if that changes anything. I need to make sure this solution can be extended to the non simplified version of the projet.


Solution

  • Apple Docs: API Reference

    setNeedsDisplay()

    You should use this method to request that a view to be redrawn only when the content or appearance of the view change. If you simply change the geometry of the view, the view is typically not redrawn. Instead, its existing content is adjusted based on the value in the view’s contentMode property. Redisplaying the existing content improves performance by avoiding the need to redraw content that has not changed.

    Basically setNeedsDisplay() redraws everything from scratch in the next drawing cycle. So the ideal way to do is create instances of UI elements only once, and update the frame or path whenever needed. It doesn't redraw everything completely thus efficient.