4.2 — Views & view hierarchy

Opening scenario

Your app’s home screen is a stack of three “cards.” On older iPhones, scrolling stutters. You open Instruments → Time Profiler → see _drawRect: consuming 40% of the main thread. You open the cards view: someone subclassed UIView and overrode draw(_:) to render a shadow with CGContext. On every scroll frame, the shadow is re-rasterized. Fix: delete draw(_:), set layer.shadowPath, scrolling jumps from 38fps to a buttery 120fps.

This chapter is about what a view actually is. UIView looks simple but hides one of the most important objects in iOS: CALayer. Once you understand the view/layer split, performance puzzles untangle themselves.

LayerOwns
UIViewTouch handling, gesture recognizers, Auto Layout participation
CALayerVisual content: backgroundColor, cornerRadius, shadows, transforms, animations

Concept → Why → How → Code

Views are layer wrappers

Every UIView has a backing CALayer. Most “visual” properties you set on UIView proxy through to the layer:

view.backgroundColor = .red          // → view.layer.backgroundColor
view.layer.cornerRadius = 12          // visual
view.layer.shadowOpacity = 0.3        // visual
view.layer.borderWidth = 1            // visual

view.addGestureRecognizer(tap)        // UIView-only — layers don't handle touch
view.isUserInteractionEnabled = false // UIView-only

Why the split? Layers are Core Animation primitives — fast, GPU-accelerated, animatable. Views add the iOS-specific responder chain (touch, gestures, accessibility). On Mac, NSView is the equivalent.

The view hierarchy

A tree:

UIWindow (also a UIView)
  └── rootViewController.view
        ├── headerView
        │     ├── titleLabel
        │     └── avatarImageView
        ├── scrollView
        │     └── contentView
        │           ├── card1
        │           ├── card2
        │           └── card3
        └── tabBarView

Each view has:

  • superview: UIView? — the parent (nil for UIWindow)
  • subviews: [UIView] — children in z-order, last drawn on top
  • addSubview(_:), removeFromSuperview(), insertSubview(_:at:), bringSubviewToFront(_:)
container.addSubview(card)            // appended on top
container.insertSubview(banner, at: 0)// behind everything
container.bringSubviewToFront(card)   // make topmost
card.removeFromSuperview()            // detach

Frames, bounds, center — coordinate systems

A frame can confuse for years until you internalize this:

PropertyCoordinate spaceMeaning
frameSuperview’s coordinates“Where I am in my parent”
boundsOwn coordinates“What my drawable area looks like” (usually origin .zero)
centerSuperview’s coordinatesShortcut for frame’s center point
let parent = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
let child  = UIView(frame: CGRect(x: 20, y: 100, width: 200, height: 100))
parent.addSubview(child)

child.frame   // (20, 100, 200, 100)   ← in parent's space
child.bounds  // (0, 0, 200, 100)      ← in own space
child.center  // (120, 150)             ← (20+200/2, 100+100/2)

bounds.origin is non-zero in scroll views — that’s how scrolling works. The scroll view changes bounds.origin.y rather than moving each subview’s frame. Subviews are drawn relative to bounds.origin, so they appear to move.

Don’t set frame if you’re using Auto Layout. Either you use Auto Layout (translatesAutoresizingMaskIntoConstraints = false) and set constraints, or you set frames manually and don’t add constraints. Mixing causes layout conflict warnings and unpredictable behavior.

Layout lifecycle

Two phases: invalidation and resolution.

Something changes (set needs layout)
        ↓
Marked dirty (setNeedsLayout)
        ↓
Run loop tick
        ↓
layoutSubviews called automatically
        ↓
You position subviews / Auto Layout solves constraints

Methods you’ll use:

view.setNeedsLayout()         // "I need a layout pass next run loop"
view.layoutIfNeeded()         // "Layout right now, synchronously"

override func layoutSubviews() {
    super.layoutSubviews()    // Auto Layout solves here
    // After super: positions are final. Adjust layer paths, etc.
    backgroundLayer.frame = bounds
    shadowLayer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}

The triggers for layoutSubviews:

  • Bounds change (rotation, window resize)
  • A subview is added/removed
  • setNeedsLayout() was called and run loop ticks
  • A constraint changes

Drawing: when to override draw(_:), and why almost never

Subclassing UIView and overriding draw(_:) triggers software rasterization on every redraw. CPU-bound. Slow. Used to be the only way to do custom rendering in iOS 3.

In 2026, you almost never need it. Alternatives:

Want to draw…Use instead
Rounded cornersview.layer.cornerRadius = 12
Shadowview.layer.shadow* + shadowPath
Borderview.layer.borderColor / borderWidth
GradientCAGradientLayer as view.layer or sublayer
Custom shapeCAShapeLayer + UIBezierPath
Image processingCore Image, Core Graphics once, cache the result
Complex animationCore Animation (CABasicAnimation, CAKeyframeAnimation)

Override draw(_:) only when you have a truly custom render that none of the above can express — a hand-drawn chart, a calligraphic signature, a Mandelbrot. Even then, render once into an UIImage and display the image; don’t redraw every frame.

CALayer essentials

You’ll use these layer classes often:

// CAShapeLayer — for arbitrary paths
let shape = CAShapeLayer()
shape.path = UIBezierPath(ovalIn: bounds).cgPath
shape.fillColor = UIColor.systemBlue.cgColor
view.layer.addSublayer(shape)

// CAGradientLayer — gradients without drawing
let gradient = CAGradientLayer()
gradient.colors = [UIColor.purple.cgColor, UIColor.blue.cgColor]
gradient.startPoint = .init(x: 0, y: 0)
gradient.endPoint   = .init(x: 1, y: 1)
gradient.frame = view.bounds
view.layer.addSublayer(gradient)

// CATextLayer — fast text (rarely needed; UILabel is fine)
// CAEmitterLayer — particle systems (confetti, sparks)
// CAReplicatorLayer — automatically replicates a sublayer (loading dots)

Layer changes are GPU-composited and almost free. Combine this with implicit Core Animation: any layer property change is automatically animated, unless you wrap it in CATransaction.setDisableActions(true).

Shadows — the perf trap

// ❌ Slow: forces off-screen rendering every frame
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.3
view.layer.shadowOffset = .init(width: 0, height: 2)
view.layer.shadowRadius = 6

// ✅ Fast: tells CA exactly what shape to shadow, no path inference needed
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.3
view.layer.shadowOffset = .init(width: 0, height: 2)
view.layer.shadowRadius = 6
view.layer.shadowPath = UIBezierPath(roundedRect: view.bounds, cornerRadius: 12).cgPath

Set shadowPath whenever bounds settle. In layoutSubviews is the right place. This single change is responsible for more “I made it 4x faster!” PRs than any other UIKit optimization.

Corner radius — the other perf trap

view.layer.cornerRadius = 12
view.layer.masksToBounds = true   // ← off-screen rendering for image clipping

With masksToBounds = true (and especially with subview content like images), the system creates an off-screen buffer to clip. Fine for static UI; expensive in scroll views.

Solutions:

  • Continuous corners (view.layer.cornerCurve = .continuous) — Apple’s iOS 13+ smoother corner shape, no extra cost
  • Pre-clip images — clip the UIImage before assigning, no live masking
  • Mask layerCAShapeLayer mask if you really need it

Hit testing

When a tap happens, UIKit walks the view hierarchy to find which view should receive the event:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // Default: returns the deepest subview at point that's visible and accepts touches
    // Override to expand hit area or intercept touches
    super.hitTest(point, with: event)
}

A view doesn’t receive touches if:

  • isUserInteractionEnabled = false
  • isHidden = true
  • alpha < 0.01
  • The touch point is outside bounds

To expand a small button’s hit area without resizing it visually:

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let expanded = bounds.insetBy(dx: -12, dy: -12)
    return expanded.contains(point)
}

This is the UIKit equivalent of SwiftUI’s .contentShape(Rectangle()).

Memory & view ownership

Views own their subviews via strong references. Removing from the hierarchy releases:

view.removeFromSuperview()    // superview drops its strong ref
// If nothing else holds `view`, it deallocates

Common leak: holding subviews in arrays you forget to clear:

class ChartView: UIView {
    private var dataPointViews: [UIView] = []

    func updateData(_ points: [CGFloat]) {
        // ❌ Leak: never empties array
        let v = UIView()
        addSubview(v)
        dataPointViews.append(v)

        // ✅ Fix: clear when redrawing
        dataPointViews.forEach { $0.removeFromSuperview() }
        dataPointViews.removeAll()
        // ... add new ones
    }
}

Debugging the hierarchy

In LLDB while paused:

po view.recursiveDescription()

In Xcode while running:

Debug → View Debugging → Capture View Hierarchy — opens the 3D view inspector. Indispensable for “where is that view hiding” bugs.

In the wild

  • Instagram Stories uses a custom view subclass with CAShapeLayer for the segmented progress bar at the top — perfect example of “shape layers over draw()”.
  • Apple Maps route lines: CAShapeLayer with animated strokeEnd for the “drawing” effect — single property animation, no per-frame work.
  • iOS Control Center sliders: custom UIView subclass with a CAGradientLayer background and gesture-driven height changes. Layout in layoutSubviews, no drawing.
  • Robinhood’s stock chart: CAShapeLayer with UIBezierPath interpolated through data points, 120fps even with 5000 points. The “live” line uses presentationLayer for in-flight position queries.

Common misconceptions

  1. “Views and layers do the same thing.” No. Views = touch + layout participation. Layers = visual content + animation. The split is what makes iOS animation fast.
  2. “I need to subclass UIView for everything.” Compose with subviews and apply layer properties. Subclassing is for behavior, not visuals.
  3. setNeedsLayout updates immediately.” No — it schedules a layout pass for the next run loop. Use layoutIfNeeded() to force synchronous.
  4. “Auto Layout is slow, use manual frames.” Auto Layout is plenty fast for typical UIs. Profile before assuming. The expensive code path is repeated constraint changes per frame.
  5. removeFromSuperview immediately deallocates the view.” Only if nothing else retains it. Arrays, closures, observers can keep it alive.

Seasoned engineer’s take

The view hierarchy is the most important data structure in your iOS app. Treat it like one:

  • Flatten what you can. Each subview is a small but real cost. A row with 12 nested containers vs 3 is measurably slower.
  • Use UIStackView for layout grouping instead of manually nesting UIViews with constraints. Less code, same perf, easier to debug.
  • hidden over removed for views you’ll toggle frequently. Add/remove costs constraints work; toggling isHidden is cheap.
  • Reuse aggressively in lists. UITableView and UICollectionView handle this for you; for one-offs (a “load more” button), reuse the same view across appearances.
  • Don’t fight Auto Layout. It will win. If a constraint produces an unsatisfiable warning, fix the constraint; never silence the warning.

TIP: Run your app in Xcode’s view debugger after every nontrivial feature. You’ll catch zombie views, overlapping constraints, and unnecessarily deep hierarchies you didn’t know you had.

WARNING: view.layer.cornerRadius = 12 without masksToBounds = true does nothing visible if the view has a background color set on the layer but content (e.g., a UIImageView) added as a subview. The cornerRadius only masks the layer’s own drawing, not subviews. Use cornerRadius + masksToBounds, accept the perf cost, or use a CAShapeLayer mask.

Interview corner

Junior-level: “What’s the difference between frame and bounds?”

frame is the view’s rectangle in its superview’s coordinate space — where it sits in its parent. bounds is in the view’s own coordinate space — usually origin .zero and the same size as the frame. Scroll views change bounds.origin to scroll their content.

Mid-level: “You see a scroll view that stutters when scrolling. What do you check first?”

Profile with Instruments (Time Profiler, Core Animation). Common culprits in order of likelihood: shadows without shadowPath, off-screen rendering from masksToBounds + cornerRadius on cell images, blending non-opaque views (Color Blended Layers debug option), too many subviews per cell, overriding draw(_:). Fix the worst offender, re-profile.

Senior-level: “Design a custom view that draws a real-time stock chart at 120 Hz with 10,000 data points.”

CAShapeLayer with a precomputed UIBezierPath. Don’t override draw(_:). For real-time updates: keep a circular buffer of points, rebuild the path on a background queue, marshal back to main, assign to shapeLayer.path. Use CATransaction.setDisableActions(true) to avoid implicit animation between frames. For 10k points, simplify the path with Douglas-Peucker before rendering; humans can’t see sub-pixel detail anyway. Test on a ProMotion device with Instruments.

Red flag in candidates: Overriding draw(_:) for shadows, rounded corners, gradients, or borders. Means they don’t know CALayer.

Lab preview

You’ll build a card stack with shadows, rounded corners, and gestures in Lab 4.1. The shadow setup is exactly the perf-aware pattern from this chapter.


Next: 4.3 — Auto Layout & constraints