UIKit Mastery Guide
Deep Technical Knowledge for Senior iOS Engineers
Table of Contents
- View Controller Lifecycle
- Auto Layout Deep Dive
- View Rendering Pipeline
- UICollectionView Advanced
- UITableView Optimization
- Memory Management
- Responder Chain & Touch Handling
- Animations & CALayer
- UIKit → SwiftUI Bridge
- Common Gotchas & Interview Questions
View Controller Lifecycle
The Complete Order
// Initial Load
init(coder:) or init(nibName:bundle:)
↓
loadView() // Creates the view hierarchy
↓
viewDidLoad() // View hierarchy is loaded into memory
↓
viewWillAppear(_:) // About to be added to view hierarchy
↓
viewWillLayoutSubviews() // About to layout subviews
↓
viewDidLayoutSubviews() // Subviews have been laid out
↓
viewDidAppear(_:) // Now visible on screen
// Going Away
viewWillDisappear(_:) // About to be removed
↓
viewDidDisappear(_:) // No longer visible
↓
deinit // Being deallocated (if no retain cycles)
Critical Details
loadView()
// When to override:
override func loadView() {
// ❌ DON'T call super.loadView()
// ❌ DON'T access self.view
// Create your custom root view
let customView = CustomView()
self.view = customView // Set the root view
}
// When NOT to override:
// - If you're using Storyboards/XIBs
// - If you just want to add subviews (use viewDidLoad instead)
viewDidLoad()
override func viewDidLoad() {
super.viewDidLoad()
// ✅ Good uses:
// - One-time setup
// - Add subviews
// - Register cells
// - Set up observers
// - Initialize data models
// ❌ Don't:
// - Access view.bounds/frame (not finalized yet)
// - Start animations
// - Fetch user-visible data (use viewWillAppear)
}
viewWillAppear vs viewDidAppear
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// ✅ Good uses:
// - Refresh data that might have changed
// - Start observing notifications
// - Update UI based on current state
// - Analytics (screen view tracking)
// Called every time view appears (not just once)
// View bounds are known here
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// ✅ Good uses:
// - Start animations
// - Begin playing video
// - Request user permissions
// - Show popups/alerts
// - Start timers
// View is fully visible and interactive
}
viewWillLayoutSubviews / viewDidLayoutSubviews
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Called multiple times:
// - When view first appears
// - When device rotates
// - When keyboard appears/disappears
// - When view.setNeedsLayout() is called
// ✅ Good for: Reading current bounds
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// ✅ Good uses:
// - Update frames based on final layout
// - Adjust CALayer properties
// - Update gradient layers
// - Adjust scroll view content size
// ⚠️ Be careful: Can be called many times
// Don't do expensive operations here
}
Common Interview Questions
Q: “When is viewDidLoad called vs viewWillAppear?”
viewDidLoad(): Once, when view is first loaded into memoryviewWillAppear(): Every time the view is about to appear (after navigation, modal dismiss, etc.)
Q: “Where should you start a network request?”
- Depends on use case:
- One-time data:
viewDidLoad()if it won’t change - Refreshable data:
viewWillAppear()to get latest - User-initiated: In response to user action
- One-time data:
Q: “Why might viewDidDisappear be called but not deinit?”
- View controller is retained somewhere (navigation stack, retain cycle, etc.)
- Strong reference from closure, delegate, notification observer
Auto Layout Deep Dive
Constraint Priorities
// Priority scale: 1 to 1000
// UILayoutPriority values:
.required // 1000 - Must be satisfied
.defaultHigh // 750
.defaultLow // 250
.fittingSizeLevel // 50
Content Hugging vs Compression Resistance
// Content Hugging: Resistance to growing
// "I don't want to be bigger than my intrinsic content size"
// Default: 250
label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
// Higher priority = more resistant to growing
// Compression Resistance: Resistance to shrinking
// "I don't want to be smaller than my intrinsic content size"
// Default: 750
label.setContentCompressionResistancePriority(.required, for: .horizontal)
// Higher priority = more resistant to shrinking
Common Layout Scenarios
Scenario 1: Two Labels, One Should Expand
// [Label1] [ Label2 ]
// ^^^^^ ^^^^^^^^^^^^^^^^
// hugs can grow
label1.setContentHuggingPriority(.defaultHigh, for: .horizontal)
label2.setContentHuggingPriority(.defaultLow, for: .horizontal)
Scenario 2: Two Labels, One Should Truncate
// [Label1...] [Label2]
// ^^^^^^^^^^ ^^^^^^^
// truncates protected
label1.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
label2.setContentCompressionResistancePriority(.required, for: .horizontal)
Scenario 3: Dynamic Height with Multi-line Label
let label = UILabel()
label.numberOfLines = 0 // Allow multiple lines
label.translatesAutoresizingMaskIntoConstraints = false
// Constraints
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16)
// No height constraint - it calculates from content
])
// UILabel provides intrinsic content size automatically
Constraint Conflicts & Debugging
// Enable constraint debugging
// Add to scheme: -UIConstraintBasedLayoutLogUnsatisfiable YES
// Identify constraints with identifiers
let constraint = label.widthAnchor.constraint(equalToConstant: 100)
constraint.identifier = "LabelWidth"
constraint.isActive = true
// Break at symbolic breakpoint: UIViewAlertForUnsatisfiableConstraints
Layout Anchors vs NSLayoutConstraint
// Modern way (Layout Anchors) - PREFER THIS
label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
// Old way (NSLayoutConstraint)
NSLayoutConstraint(
item: label,
attribute: .centerX,
relatedBy: .equal,
toItem: view,
attribute: .centerX,
multiplier: 1.0,
constant: 0
).isActive = true
Safe Area Considerations
// iOS 11+: Safe Area Layout Guide
view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
// Pre-iOS 11 (don't use these anymore):
// topLayoutGuide, bottomLayoutGuide (deprecated)
Intrinsic Content Size
// Custom views can provide their own intrinsic content size
class CustomView: UIView {
override var intrinsicContentSize: CGSize {
return CGSize(width: 100, height: 50)
}
func contentChanged() {
// Tell Auto Layout to recalculate
invalidateIntrinsicContentSize()
}
}
Common Auto Layout Mistakes
// ❌ MISTAKE 1: Forgetting to set translatesAutoresizingMaskIntoConstraints
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = true // Default!
// This creates conflicting constraints with auto-resizing mask
// ✅ CORRECT
view.translatesAutoresizingMaskIntoConstraints = false
// ❌ MISTAKE 2: Not activating constraints
let constraint = view.widthAnchor.constraint(equalToConstant: 100)
// Forgot: constraint.isActive = true
// ✅ CORRECT
constraint.isActive = true
// Or use NSLayoutConstraint.activate([constraint])
// ❌ MISTAKE 3: Over-constraining
view.widthAnchor.constraint(equalToConstant: 100).isActive = true
view.widthAnchor.constraint(equalToConstant: 200).isActive = true // Conflict!
// ✅ CORRECT: Update existing constraint or deactivate old one
View Rendering Pipeline
The Three Steps
1. Update Constraints (Bottom-Up)
updateConstraints() / setNeedsUpdateConstraints()
↓
2. Layout Subviews (Top-Down)
layoutSubviews() / setNeedsLayout() / layoutIfNeeded()
↓
3. Display/Draw (Top-Down)
draw(_:) / setNeedsDisplay()
Update Constraints Phase
override func updateConstraints() {
// Called when constraints need updating
// Bottom-up: children before parents
// Update your constraints here
myConstraint.constant = newValue
// MUST call super at the END
super.updateConstraints()
}
// Trigger constraint update (async)
view.setNeedsUpdateConstraints()
Layout Phase
override func layoutSubviews() {
// Called when view's bounds change or constraints are resolved
// Top-down: parents before children
// MUST call super FIRST
super.layoutSubviews()
// Update frames, CALayers, etc.
gradientLayer.frame = bounds
// Don't call setNeedsLayout() here (infinite loop!)
}
// Trigger layout (async)
view.setNeedsLayout()
// Force immediate layout
view.layoutIfNeeded()
Display/Draw Phase
override func draw(_ rect: CGRect) {
// Called when view needs to render its content
// Use Core Graphics to draw
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(UIColor.red.cgColor)
context.fill(rect)
// ⚠️ EXPENSIVE - avoid if possible
// Use UIImageView, CALayer, or other optimized approaches
}
// Trigger redraw (async)
view.setNeedsDisplay()
// Trigger partial redraw
view.setNeedsDisplay(specificRect)
When Each Method is Called
| Method | When Called | Frequency |
|---|---|---|
| updateConstraints() | Constraints change | As needed |
| layoutSubviews() | Bounds change, constraints resolve | Often |
| draw(_:) | View needs rendering | Rarely (cached) |
setNeedsLayout vs layoutIfNeeded
// setNeedsLayout() - Async
UIView.animate(withDuration: 0.3) {
self.view.setNeedsLayout() // ❌ Won't animate
}
// layoutIfNeeded() - Immediate
UIView.animate(withDuration: 0.3) {
self.constraint.constant = 100
self.view.layoutIfNeeded() // ✅ Animates the layout change
}
Common Pattern: Animating Constraint Changes
// 1. Update constraint
heightConstraint.constant = 200
// 2. Mark for layout
view.setNeedsLayout()
// 3. Animate the layout
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
UICollectionView Advanced
Custom Flow Layout
class CustomFlowLayout: UICollectionViewFlowLayout {
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
// Calculate item size based on collection view size
let availableWidth = collectionView.bounds.width - sectionInset.left - sectionInset.right
let itemWidth = (availableWidth / 2) - minimumInteritemSpacing
itemSize = CGSize(width: itemWidth, height: itemWidth)
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// Return true if layout should recalculate on bounds change (e.g., rotation)
guard let oldBounds = collectionView?.bounds else { return false }
return oldBounds.width != newBounds.width
}
}
Completely Custom Layout
class WaterfallLayout: UICollectionViewLayout {
private var cache: [UICollectionViewLayoutAttributes] = []
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else { return 0 }
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func prepare() {
guard cache.isEmpty,
let collectionView = collectionView else { return }
// Calculate layout attributes for each item
let columnWidth = contentWidth / 2
var xOffsets: [CGFloat] = []
var yOffsets: [CGFloat] = Array(repeating: 0, count: 2)
for column in 0..<2 {
xOffsets.append(CGFloat(column) * columnWidth)
}
for item in 0..<collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: item, section: 0)
// Determine which column to use (shortest)
let column = yOffsets[0] < yOffsets[1] ? 0 : 1
let x = xOffsets[column]
let y = yOffsets[column]
// Calculate height (from delegate or fixed)
let height: CGFloat = 200 // Or get from delegate
let frame = CGRect(x: x, y: y, width: columnWidth, height: height)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frame
cache.append(attributes)
contentHeight = max(contentHeight, frame.maxY)
yOffsets[column] += height
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cache.filter { $0.frame.intersects(rect) }
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func invalidateLayout() {
super.invalidateLayout()
cache.removeAll()
contentHeight = 0
}
}
Prefetching
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
collectionView.prefetchDataSource = self
}
}
extension MyViewController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Start loading data/images for these cells
// They're about to scroll into view
for indexPath in indexPaths {
imageLoader.loadImage(for: items[indexPath.item])
}
}
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
// User scrolled away, cancel these operations
for indexPath in indexPaths {
imageLoader.cancelLoad(for: items[indexPath.item])
}
}
}
Compositional Layout (Modern Approach)
// iOS 13+
func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.5),
heightDimension: .fractionalHeight(1.0)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(200)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
return UICollectionViewCompositionalLayout(section: section)
}
Diffable Data Source (Modern Approach)
// iOS 13+
enum Section {
case main
}
typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
class MyViewController: UIViewController {
private var dataSource: DataSource!
override func viewDidLoad() {
super.viewDidLoad()
configureDataSource()
applyInitialSnapshot()
}
func configureDataSource() {
dataSource = DataSource(collectionView: collectionView) {
collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "Cell",
for: indexPath
) as! MyCell
cell.configure(with: item)
return cell
}
}
func applyInitialSnapshot() {
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
}
func updateItems(_ newItems: [Item]) {
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems(newItems)
dataSource.apply(snapshot, animatingDifferences: true)
// Automatically calculates diff and animates!
}
}
UITableView Optimization
Cell Reuse Done Right
// ❌ Old way (string identifiers, casting)
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyCell
// ✅ Better way (type-safe)
class MyCell: UITableViewCell {
static let reuseIdentifier = String(describing: self)
}
// Register
tableView.register(MyCell.self, forCellReuseIdentifier: MyCell.reuseIdentifier)
// Dequeue
let cell = tableView.dequeueReusableCell(
withIdentifier: MyCell.reuseIdentifier,
for: indexPath
) as! MyCell
Automatic Height Calculation
// Self-sizing cells
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100 // Important for performance!
// In your cell
class MyCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
// Setup constraints
// Make sure top and bottom constraints are set!
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
// ↑ This bottom constraint is CRITICAL for auto height
])
}
}
Performance Optimization
// 1. Use estimated heights (prevents upfront calculation of all rows)
tableView.estimatedRowHeight = 100
tableView.estimatedSectionHeaderHeight = 40
tableView.estimatedSectionFooterHeight = 0
// 2. Implement height caching for complex calculations
private var heightCache: [IndexPath: CGFloat] = [:]
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let cached = heightCache[indexPath] {
return cached
}
let height = calculateHeight(for: indexPath)
heightCache[indexPath] = height
return height
}
// 3. Avoid expensive operations in cellForRowAt
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
// ❌ Don't do this
cell.imageView?.image = processImage(largeImage) // Expensive!
// ✅ Do this
cell.imageView?.image = cachedImages[indexPath.item]
return cell
}
// 4. Load images asynchronously
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! ImageCell
let item = items[indexPath.row]
cell.configure(with: item)
// Load image async
imageLoader.loadImage(for: item.imageURL) { [weak cell] image in
// Check if cell is still for this item (reuse protection)
guard cell?.imageURL == item.imageURL else { return }
cell?.imageView.image = image
}
return cell
}
Cell Reuse Pitfalls
class MyCell: UITableViewCell {
private var imageLoadTask: URLSessionDataTask?
override func prepareForReuse() {
super.prepareForReuse()
// ✅ CRITICAL: Clean up when cell is reused
imageView.image = nil
imageLoadTask?.cancel()
imageLoadTask = nil
// Reset any cell state
titleLabel.text = nil
isExpanded = false
}
}
Batch Updates
// Update multiple rows efficiently
tableView.performBatchUpdates({
tableView.insertRows(at: [indexPath1, indexPath2], with: .automatic)
tableView.deleteRows(at: [indexPath3], with: .fade)
tableView.reloadRows(at: [indexPath4], with: .none)
}, completion: nil)
// Or use the block-based API
tableView.beginUpdates()
tableView.insertRows(at: [indexPath], with: .automatic)
tableView.endUpdates()
Memory Management
Strong, Weak, Unowned
// Strong (default)
class Parent {
var child: Child? // Strong reference
}
class Child {
var parent: Parent? // ❌ Retain cycle!
}
// ✅ Fix with weak
class Child {
weak var parent: Parent? // Weak reference (optional)
}
// Unowned (use when reference is never nil)
class Child {
unowned var parent: Parent // Unowned reference (non-optional)
// ⚠️ Crashes if parent is deallocated
}
Closure Capture Lists
// ❌ Retain cycle
class MyViewController: UIViewController {
var name = "Test"
func setupClosure() {
someAsyncOperation { result in
self.name = result // Strong capture of self
}
}
}
// ✅ Fix with weak
func setupClosure() {
someAsyncOperation { [weak self] result in
guard let self = self else { return }
self.name = result
}
}
// ✅ Or use unowned (if you're certain self won't be nil)
func setupClosure() {
someAsyncOperation { [unowned self] result in
self.name = result // Crashes if self is deallocated
}
}
// Complex capture list
func setupClosure() {
someAsyncOperation { [weak self, weak delegate = self.delegate, strongModel = model] in
guard let self = self else { return }
// self is weak
// delegate is weak
// strongModel is strong
}
}
Delegate Pattern Memory Management
// ✅ Always use weak for delegates
protocol MyDelegate: AnyObject { // AnyObject = class-only protocol
func didComplete()
}
class MyClass {
weak var delegate: MyDelegate? // Weak to prevent retain cycle
}
Common Retain Cycles
// 1. Timer retain cycle
class MyViewController: UIViewController {
var timer: Timer?
func startTimer() {
// ❌ Timer retains target (self)
timer = Timer.scheduledTimer(
timeInterval: 1.0,
target: self, // Strong reference!
selector: #selector(timerFired),
userInfo: nil,
repeats: true
)
}
deinit {
timer?.invalidate() // Won't be called if retained!
}
}
// ✅ Fix: Use block-based timer (iOS 10+)
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
self?.timerFired()
}
}
// 2. Notification observer retain cycle (old API)
override func viewDidLoad() {
super.viewDidLoad()
// Pre-iOS 9: NotificationCenter retained observers
// ✅ iOS 9+: No longer retains
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .someNotification,
object: nil
)
}
deinit {
// Still good practice to remove
NotificationCenter.default.removeObserver(self)
}
// ✅ Better: Block-based with weak self
var observer: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
observer = NotificationCenter.default.addObserver(
forName: .someNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleNotification(notification)
}
}
deinit {
if let observer = observer {
NotificationCenter.default.removeObserver(observer)
}
}
Memory Warnings
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Clear caches
imageCache.removeAll()
// Release non-critical resources
heavyData = nil
}
// Also implement in AppDelegate
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
// Clear app-level caches
URLCache.shared.removeAllCachedResponses()
}
Responder Chain & Touch Handling
The Responder Chain
Touch Event
↓
UIView (hit-tested view)
↓
Superview
↓
Superview
↓
UIViewController
↓
UIWindow
↓
UIApplication
↓
UIApplicationDelegate
Hit Testing
// How iOS finds which view was touched
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1. Check if can receive touches
guard isUserInteractionEnabled,
!isHidden,
alpha > 0.01,
self.point(inside: point, with: event) else {
return nil
}
// 2. Check subviews (reverse order - top to bottom)
for subview in subviews.reversed() {
let convertedPoint = convert(point, to: subview)
if let hitView = subview.hitTest(convertedPoint, with: event) {
return hitView
}
}
// 3. Return self if no subview handles it
return self
}
// Override to extend touch area
class BiggerTouchButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// Extend touch area by 20 points in all directions
let biggerBounds = bounds.insetBy(dx: -20, dy: -20)
return biggerBounds.contains(point)
}
}
// Override to pass touches through transparent areas
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hitView = super.hitTest(point, with: event)
// Pass through if the hit view is self
return hitView == self ? nil : hitView
}
}
Touch Handling
class CustomView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
// Handle touch start
print("Touch began at: \(location)")
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
// Handle touch move (dragging)
print("Touch moved to: \(location)")
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
// Handle touch end
print("Touch ended at: \(location)")
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
// Handle cancellation (phone call, notification, etc.)
print("Touch cancelled")
}
}
Gesture Recognizers
// Basic usage
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
view.addGestureRecognizer(tap)
// Gesture recognizer delegate
extension MyViewController: UIGestureRecognizerDelegate {
// Allow simultaneous recognition
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return true // Allow both pan and pinch at same time
}
// Control whether gesture should begin
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// Custom logic
if gestureRecognizer is UIPanGestureRecognizer {
let pan = gestureRecognizer as! UIPanGestureRecognizer
let velocity = pan.velocity(in: view)
// Only recognize horizontal pans
return abs(velocity.x) > abs(velocity.y)
}
return true
}
}
Animations & CALayer
UIView Animations
// Basic animation
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0.5
self.view.frame.origin.y += 100
}
// With completion
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
}) { finished in
self.view.removeFromSuperview()
}
// With options
UIView.animate(
withDuration: 0.3,
delay: 0.1,
options: [.curveEaseInOut, .allowUserInteraction],
animations: {
self.view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
}
)
// Spring animation
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7, // 0-1, lower = more bounce
initialSpringVelocity: 0.5,
options: [],
animations: {
self.view.center = targetPoint
}
)
// Keyframe animations
UIView.animateKeyframes(
withDuration: 2.0,
delay: 0,
options: [],
animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.25) {
self.view.alpha = 0.5
}
UIView.addKeyframe(withRelativeStartTime: 0.25, relativeDuration: 0.25) {
self.view.alpha = 1.0
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
self.view.transform = CGAffineTransform(rotationAngle: .pi)
}
}
)
Animatable Properties
// ✅ Animatable
view.frame
view.bounds
view.center
view.transform
view.alpha
view.backgroundColor
// ❌ Not animatable with UIView.animate
view.layer.cornerRadius
view.layer.shadowOpacity
view.layer.contents (image)
// Use CABasicAnimation for layer properties
CALayer Deep Dive
// Every UIView has a layer
let view = UIView()
view.layer.cornerRadius = 10
view.layer.masksToBounds = true // Needed for cornerRadius to clip
// Shadow (don't use masksToBounds with shadow!)
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.5
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowRadius = 4
// Border
view.layer.borderWidth = 2
view.layer.borderColor = UIColor.red.cgColor
// Rounding specific corners
let path = UIBezierPath(
roundedRect: view.bounds,
byRoundingCorners: [.topLeft, .topRight],
cornerRadii: CGSize(width: 10, height: 10)
)
let mask = CAShapeLayer()
mask.path = path.cgPath
view.layer.mask = mask
CABasicAnimation
// Animate layer properties
let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.fromValue = 0
animation.toValue = 20
animation.duration = 0.3
view.layer.add(animation, forKey: "cornerRadius")
// ⚠️ Animation doesn't actually change the property!
view.layer.cornerRadius = 20 // Must set final value
// To make animation persist
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
// ⚠️ Still doesn't change underlying value - set it manually
Gradient Layers
let gradientLayer = CAGradientLayer()
gradientLayer.frame = view.bounds
gradientLayer.colors = [
UIColor.red.cgColor,
UIColor.blue.cgColor
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
view.layer.insertSublayer(gradientLayer, at: 0)
// ⚠️ Update frame in layoutSubviews
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
UIKit → SwiftUI Bridge
UIViewRepresentable
// Wrap UIKit view for use in SwiftUI
struct ActivityIndicator: UIViewRepresentable {
@Binding var isAnimating: Bool
let style: UIActivityIndicatorView.Style
func makeUIView(context: Context) -> UIActivityIndicatorView {
let indicator = UIActivityIndicatorView(style: style)
return indicator
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
if isAnimating {
uiView.startAnimating()
} else {
uiView.stopAnimating()
}
}
}
// Usage in SwiftUI
struct ContentView: View {
@State private var isLoading = true
var body: some View {
ActivityIndicator(isAnimating: $isLoading, style: .large)
}
}
UIViewControllerRepresentable
// Wrap UIKit view controller for SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// Update if needed
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]
) {
if let image = info[.originalImage] as? UIImage {
parent.image = image
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
Hosting SwiftUI in UIKit
// Embed SwiftUI view in UIKit
import SwiftUI
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Create SwiftUI view
let swiftUIView = MySwiftUIView()
// Wrap in hosting controller
let hostingController = UIHostingController(rootView: swiftUIView)
// Add as child
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
hostingController.didMove(toParent: self)
}
}
Common Gotchas & Interview Questions
Q: “Why does my view controller not deallocate?”
Common causes:
- Retain cycle with closure
- Timer with target: self
- Notification observer (pre-iOS 9)
- Delegate not marked weak
- Still in navigation stack
// Debug deallocation
deinit {
print("MyViewController deallocated")
}
Q: “Why is my collection view cell showing wrong data?”
Cause: Cell reuse not handled properly
// ✅ Always reset in prepareForReuse
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
titleLabel.text = nil
}
Q: “Why are my Auto Layout animations not working?”
// ❌ Wrong
UIView.animate(withDuration: 0.3) {
self.constraint.constant = 100
}
// ✅ Correct
self.constraint.constant = 100
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
Q: “What’s the difference between frame and bounds?”
- frame: Position and size in superview’s coordinate system
- bounds: Position (usually 0,0) and size in own coordinate system
let view = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100))
// frame = (50, 50, 100, 100)
// bounds = (0, 0, 100, 100)
view.bounds.origin = CGPoint(x: 10, y: 10)
// frame = (50, 50, 100, 100) - unchanged
// bounds = (10, 10, 100, 100)
// Content shifts, but view position doesn't change
Q: “When should you use weak vs unowned?”
- weak: Reference might become nil (optional)
- unowned: Reference will never be nil (crashes if wrong)
// Use weak when unsure
class MyViewController {
weak var delegate: MyDelegate?
}
// Use unowned for guaranteed lifetime relationships
class Child {
unowned let parent: Parent // Child can't exist without parent
init(parent: Parent) {
self.parent = parent
}
}
Q: “What’s the difference between layoutIfNeeded and setNeedsLayout?”
- setNeedsLayout(): Marks view as needing layout, happens on next update cycle (async)
- layoutIfNeeded(): Forces immediate layout if view is marked as needing layout (sync)
Q: “How do you prevent retain cycles in NotificationCenter?”
// Modern API (iOS 9+) - doesn't retain observer
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .myNotification,
object: nil
)
// Block-based - use weak self
observer = NotificationCenter.default.addObserver(
forName: .myNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handle(notification)
}
Q: “What’s the difference between viewWillLayoutSubviews and layoutSubviews?”
- viewWillLayoutSubviews: View controller method, called before layout
- layoutSubviews: UIView method, called when view actually lays out
Quick Reference
Must-Know Methods
// View Controller
viewDidLoad()
viewWillAppear(_:)
viewDidAppear(_:)
viewWillDisappear(_:)
viewDidDisappear(_:)
// View
layoutSubviews()
draw(_:)
hitTest(_:with:)
point(inside:with:)
// Auto Layout
updateConstraints()
setNeedsLayout()
layoutIfNeeded()
// Cell
prepareForReuse()
Performance Checklist
- Use cell reuse identifiers correctly
- Implement prepareForReuse()
- Set estimatedRowHeight
- Cache expensive calculations
- Load images asynchronously
- Use prefetching for collection/table views
- Profile with Instruments
- Check for retain cycles with Memory Graph
- Minimize work in cellForRowAt
- Use shadow path for shadows (performance)
This covers the deep UIKit knowledge expected at a senior level. Focus on the “why” behind each concept - that’s what separates senior engineers from mid-level.