cr8rcho
Development

Fixing PKToolPicker Collapsed State Icon Bug

Feb 10, 2025
7 min

Problem

When entering a canvas screen on iPad, the PKToolPicker initially appeared with its collapsed/minimized circular button showing no tool icon — just a blank black circle.

The icon only appeared correctly after the user manually expanded the picker, selected or changed a tool, and collapsed it again.

Scope

  • Multiple PKCanvasView instances across several screens in the app
  • All canvases share a single PKToolPicker instance

Root Cause

The PKToolPicker's internal collapsed view does not perform an initial layout pass when shown programmatically. Even after calling setVisible(_:forFirstResponder:), the layout needed to render the tool icon in the collapsed state is not automatically triggered — an internal Apple framework behavior.

Solution

The fix combined two changes:

1. UIViewRepresentable → UIViewControllerRepresentable

Changed the canvas wrapper view from UIViewRepresentable to UIViewControllerRepresentable to leverage the viewDidAppear lifecycle. Following Apple's WWDC19 recommended pattern, the tool picker is set up in viewDidAppear.

Reason: UIViewRepresentable's makeUIView/updateUIView can be called before the view is fully added to the window, making picker initialization timing unreliable.

2. Force Redraw via Window Hierarchy Traversal

After the picker is shown, the window hierarchy is traversed on the next run loop cycle to find picker-related internal views and force layout/display.

private static func forcePickerRedraw(in view: UIView) {
    let className = String(describing: type(of: view))
    if className.contains("Picker") || className.contains("Floating") || className.contains("Palette") {
        view.setNeedsLayout()
        view.layoutIfNeeded()
        view.setNeedsDisplay()
        for subview in view.subviews {
            subview.setNeedsLayout()
            subview.layoutIfNeeded()
            subview.setNeedsDisplay()
        }
    }
    for subview in view.subviews {
        forcePickerRedraw(in: subview)
    }
}

Call timing: Invoked via DispatchQueue.main.async after becomeFirstResponder(), delaying by one run loop cycle so that the picker has been added to the window before forcing layout.

picker.setVisible(isToolPickerVisible, forFirstResponder: canvasView)
if isToolPickerVisible {
    canvasView.becomeFirstResponder()
    DispatchQueue.main.async { [weak self] in
        guard let window = self?.canvasView.window else { return }
        Self.forcePickerRedraw(in: window)
    }
}

Approaches That Didn't Work

ApproachResult
Setting selectedToolItemIdentifierNot reflected in the collapsed icon
Assigning PKToolPickerInkingItem to selectedToolItemInternal tool selected but collapsed icon not rendered
stateAutosaveName = nilOnly prevented collapsed state restoration; icon issue remained
PKCanvasView subclass + didMoveToWindow()Unstable behavior
Toggle button for Picker on/offNot a root fix; blank circle still shown on toggle
becomeFirstResponder()setVisible() orderApple recommends the opposite order; reordering alone didn't fix it
UIViewControllerRepresentable + viewDidAppear onlyImproved timing but collapsed icon still not displayed

Changes Summary

  • Created a dedicated UIViewController subclass as part of converting the canvas wrapper to UIViewControllerRepresentable
  • Added forcePickerRedraw(in:) method

Key Takeaways

  • PKToolPicker's collapsed view does not perform its own initial layout when shown programmatically
  • Setting up the picker in viewDidAppear is the correct timing, but it alone is not enough
  • You must find picker-related views in the window hierarchy and force setNeedsLayout/layoutIfNeeded/setNeedsDisplay for the collapsed icon to render
  • This forced layout must run on the next run loop after becomeFirstResponder() to be effective