UIWindow is the root of a scene's view hierarchy: a UIView subclass with no superview that holds the rootViewController and receives events from UIApplication to pass to the views inside it.
How it starts: when a scene connects, the scene delegate (or SwiftUI's WindowGroup) creates a UIWindow for the UIWindowScene, sets rootViewController and calls makeKeyAndVisible(). The root controller's view becomes the window's content; every other view is added beneath it.
What makes it different: a window is attached to a screen through its windowScene, it is the first stop when UIApplication dispatches an event, and the key window receives keyboard and other non-touch events.
How many: usually one per scene. An extra window is for content that must sit above everything, ordered by windowLevel. Per Apple's TN3187, the SDK that follows iOS 26 will not launch a UIKit app that has not adopted the scene lifecycle, so window creation belongs in scene(_:willConnectTo:options:).
view.window stays nil until a view is actually inside a window's hierarchy, which is a quick check for "is this view on screen yet".
iOSJunior
UIKit Fundamentals
UIView, UIViewController lifecycle, view hierarchy, touch handling
Questions closed
0 / 25
- 0Review
- 6Asked often
Questions
loadView and viewDidLoad run once per controller instance; viewWillAppear, viewIsAppearing, viewDidAppear and the two disappear callbacks run every time the screen is shown or hidden.
init: the controller exists, its view does not yet.
loadView() → viewDidLoad(): the view is created the first time something reads view. One-time setup goes in viewDidLoad: subviews, constraints, delegates, the first data request.
viewWillAppear(_:): about to be shown. Refresh data that may have changed while the screen was hidden.
viewIsAppearing(_:): the view is in the hierarchy with its final size and traits. Geometry-dependent setup goes here.
viewDidAppear(_:): the transition has finished. Start animations, log the screen view.
viewWillDisappear(_:) / viewDidDisappear(_:): save drafts, pause video and timers.
viewWillLayoutSubviews() / viewDidLayoutSubviews(): can run many times per appearance, so keep them cheap.
Start a network load in viewDidLoad when once is enough, or in viewWillAppear when the data must be fresh on every visit. Heavy synchronous work in viewWillAppear delays the push animation, so keep it asynchronous.
loadView creates the controller's root view and assigns it to view; viewDidLoad runs right after it and configures a view that already exists. You override loadView only to make your own view class the root view.
UIKit calls loadView lazily, the first time anything reads view. The default implementation loads the nib or storyboard scene if there is one, otherwise it creates a plain UIView.
In an override, assign view and do not call super; it would build a view you immediately throw away.
Never call loadView() yourself. To force loading, for example in a unit test, call loadViewIfNeeded().
Reading view too early (in init, or from a parent before the controller is shown) loads it early, so viewDidLoad runs sooner than you expect.
frame is the view's position and size in its superview's coordinate system; bounds is the same rectangle in the view's own coordinates. bounds.origin is non-zero when the view shows a shifted part of its content: a scroll view's bounds.origin is its contentOffset.
center is also in superview coordinates. Setting it moves the view without touching bounds.
Subviews are positioned in their parent's bounds, so shifting bounds.origin moves every child on screen at once; this is exactly how UIScrollView scrolls.
With a non-identity transform (rotation, scale), frame becomes the bounding box of the transformed view and setting it is undefined. Use bounds for size, center for position and transform for the rest.
All three produce the same UIKit views: storyboards and XIBs give you a visual editor, code gives readable diffs and far fewer merge conflicts. Most teams now build new screens in code or SwiftUI.
Storyboards: many screens and the segues between them in one file; a segue is a transition defined in the storyboard, and a storyboard identifier is the string used to instantiate a controller from code. Quick to prototype, but the XML conflicts in merges, large files open slowly, and segue and controller identifiers are strings that fail only at runtime.
XIBs: one view or screen per file. A smaller blast radius; a reasonable fit for a reusable custom view or cell.
Code: everything in Swift: dependencies go through init, refactoring tools work, diffs are readable. More typing, and no canvas unless you add a #Preview.
Launch screen: still a storyboard or the UILaunchScreen key in Info.plist. It is static and cannot run any code.
A controller from a storyboard is created through init(coder:). instantiateViewController(identifier:creator:) (iOS 13+) still lets you pass dependencies into it.
@IBOutlet is a property Interface Builder fills with a reference to a view, so code can read and change that view; @IBAction is a method Interface Builder connects to a control event, so the control can call your code. The second one is target-action.
Outlets are usually weak var titleLabel: UILabel!: the view hierarchy already owns the view, and the ! says it is set once the view has loaded. Using it before viewDidLoad crashes on nil.
Target-action: a control stores a target object, a selector and the events, and calls that method when the event fires. In code it is addTarget(_:action:for:) with #selector(...).
A selector is looked up at runtime by the Objective-C runtime, so the method must be @objc (@IBAction implies it).
A renamed outlet still connected in the storyboard crashes with "not key value coding-compliant"; a renamed action crashes with "unrecognized selector sent to instance".
Since iOS 14, UIAction closures (addAction(_:for:), UIButton(primaryAction:)) avoid selectors entirely.
UINavigationController manages a stack of view controllers: push adds one on top with a back button and swipe-back, pop removes it. present shows a controller modally above the current one, outside any stack, until it is dismissed.
Push: drill-down within one flow (list → detail). It needs a navigation controller: without one navigationController is nil and the push silently does nothing.
Present: a separate task (compose, log in, filters). Since iOS 13 the default style is .pageSheet, which the user can swipe away; .fullScreen cannot be swiped. Close it with dismiss(animated:).
A .pageSheet leaves the presenter visible, so the presenter's viewWillDisappear and viewWillAppear do not fire around it.
A presented screen that needs a title bar or its own pushes is wrapped in its own UINavigationController.
setViewControllers(_:animated:) replaces the whole stack, which is how a deep link opens three screens deep.
UITabBarController sits above this: each tab usually holds its own navigation controller, so every tab keeps an independent stack.
A table view asks its data source how many rows there are and for a cell for each visible row, and tells its delegate about selection, sizing and scrolling. Only on-screen cells exist; cells that scroll off go into a reuse queue and come back from dequeueReusableCell(withIdentifier:for:).
Data source (UITableViewDataSource): numberOfRowsInSection and cellForRowAt. It answers what to show.
Delegate (UITableViewDelegate): didSelectRowAt, row heights, swipe actions, and scroll callbacks, since it inherits UIScrollViewDelegate.
Reuse: 10,000 rows need only about as many cells as fit on screen, which keeps memory flat and scrolling smooth.
A reused cell still shows the previous row's content, so cellForRowAt must set every field, and prepareForReuse() resets state such as a pending image download.
After the data changes, call reloadData() or performBatchUpdates(_:completion:), or apply a snapshot through a diffable data source.
A scroll view is a view whose bounds.origin moves as the user drags: contentSize is how big the scrollable content is, contentOffset is which point of it sits at the top-left corner, and contentInset adds extra scrollable space around it.
It scrolls only when contentSize is larger than its own bounds. With Auto Layout, contentSize comes from constraints to contentLayoutGuide.
contentOffset is the same value as bounds.origin. Setting it, or calling setContentOffset(_:animated:), scrolls in code.
contentInset adds padding, for example room for a toolbar. adjustedContentInset adds the safe area on top of it, controlled by contentInsetAdjustmentBehavior.
The delegate's scrollViewDidScroll(_:) fires on every offset change (used for collapsing headers and loading the next page), so keep it cheap.
UIImage is image data (pixels and scale) that is not on screen; UIImageView is a view that shows it using contentMode. .scaleAspectFit shows the whole image with empty bands, .scaleAspectFill fills the view and crops the overflow.
.scaleToFill, the default, stretches the image to the view's shape and distorts it.
.scaleAspectFit shows the whole image without distortion and leaves empty bands.
.scaleAspectFill covers the whole view without distortion and crops the overflow, but only with clipsToBounds = true; otherwise the overflow draws outside the view.
Round with layer.cornerRadius plus clipsToBounds and cast the shadow with layer.shadowOpacity/shadowRadius/shadowOffset; clipping cuts the shadow off, so use two views: an outer one with the shadow and an inner one that clips.
cornerRadius alone rounds only the layer's background and border. Subviews and image content keep square corners until clipsToBounds (the same as layer.masksToBounds) is on.
Why two views work: the outer container does not clip, so its shadow stays visible; the inner view clips its content with the same radius.
Set layer.shadowPath. Without it Core Animation works out the shadow's shape from the content in an offscreen pass every frame, which hurts scrolling.
layer.cornerCurve = .continuous gives Apple's smooth corner shape; layer.maskedCorners rounds only selected corners.
A circle needs cornerRadius = bounds.width / 2 recomputed in layoutSubviews, because the size can change.
The dataTask completion handler runs on a background queue, and UIKit may only be used on the main thread. Update the UI on the main thread: wrap it in DispatchQueue.main.async, or use async/await from a main-actor context.
The Main Thread Checker, on by default in debug builds, names the line: "UILabel.text must be used from main thread only".
In Swift 6 language mode this does not compile: view controllers are @MainActor, and the handler is a @Sendable closure that is not.
Callback fix: decode on the background queue, then wrap only the UI update in DispatchQueue.main.async.
async/await fix: call URLSession.shared.data(from:) in a Task started from the view controller. The task inherits the main actor, so after await the code is back on the main thread.
Safe off the main thread: networking, JSON decoding, image decoding and drawing with UIGraphicsImageRenderer.
Use dynamic colours (system semantic colours such as .label and .systemBackground, or asset catalog colours with a dark variant), and UIKit re-resolves them on every appearance change. A CGColor set on a layer is resolved once, so you have to set it again when the trait changes.
Semantic colours: .label, .secondaryLabel, .systemBackground, .separator adapt on their own.
Asset catalog: a colour or image set with Any and Dark appearances; UIColor(named:) returns a dynamic colour.
In code: UIColor { traits in ... } returns a different colour per userInterfaceStyle.
Layers: borderColor, shadowColor and gradient colours are CGColors. Resolve them with resolvedColor(with: traitCollection) and update them in a registerForTraitChanges handler (iOS 17+), which replaced the deprecated traitCollectionDidChange(_:). Since iOS 18 setting them in layoutSubviews also works, because UIKit tracks the traits read there and re-runs it.
overrideUserInterfaceStyle = .dark pins one style on a view or controller subtree.
Put the property changes inside UIView.animate(withDuration:animations:). For Auto Layout, change the constraint's constant first, then call layoutIfNeeded() on the superview inside the animation block; it is the layout pass that gets animated.
Animatable view properties: frame, bounds, center, transform, alpha, backgroundColor. isHidden and text are not; cross-fade them with UIView.transition(with:duration:options:animations:) and .transitionCrossDissolve.
Under Auto Layout, do not animate frame: the next layout pass puts it back. Change constraints, or use transform, which layout ignores.
Call layoutIfNeeded() once before changing the constraint, so the animation starts from a settled layout instead of animating unrelated pending changes too.
UIView.animate(springDuration:bounce:) (iOS 17+) describes a spring by feel. UIViewPropertyAnimator can be paused, reversed and scrubbed, which is what gesture-driven animations need.
Layer-only properties such as strokeEnd or shadowPath need Core Animation (CABasicAnimation).
viewIsAppearing runs once per appearance, after the view is in the hierarchy with its final size and traits but before it is on screen. viewWillAppear is too early for geometry, and viewDidLayoutSubviews runs on every layout pass.
Order per appearance: viewWillAppear → viewIsAppearing → viewDidAppear. It was added in iOS 17 and back-deploys to iOS 13 when you build with Xcode 15 or later.
In viewWillAppear the view may not be in the window yet, and bounds and traitCollection can still be stale, so a scroll position or a size calculation lands in the wrong place.
viewDidLayoutSubviews has the right numbers but fires on rotation, keyboard changes and cell resizes, hence the old didScrollOnce flag. viewIsAppearing removes the need for it.
Typical uses: scroll a list to the current item, size a header from the real width, apply trait-dependent styling.
The observer is added in viewWillAppear, which runs on every appearance, and is never removed. NotificationCenter does not deduplicate registrations, so after three visits one .cartDidChange calls cartDidChange() three times: three analytics events, three reloads.
Why: viewDidLoad runs once per controller instance, viewWillAppear every time the screen is shown: after a pop back, a dismissed modal, a tab switch. One-time setup placed there piles up; the same goes for addSubview, addGestureRecognizer or a Combine sink.
Fix: register once in viewDidLoad and keep only refresh work such as reloadBadge() in viewWillAppear. A selector-based observer is removed automatically when the controller is deallocated (since iOS 9).
Visible-only alternative: if the screen should listen only while on screen, pair the calls: add in viewWillAppear, call removeObserver(self, name: .cartDidChange, object: nil) in viewWillDisappear.
The block-based addObserver(forName:object:queue:using:) is worse: each call returns a token you must keep and remove, and a closure that captures self strongly also leaks the controller.
Catch it: a test that calls viewWillAppear twice, posts the notification once and asserts exactly one analytics event.
Forward, give the next controller what it needs before showing it, ideally through its init. Back, the child reports through a callback the parent set (a closure or a weak delegate), never through a direct reference to the parent's type.
**Forward through init**: EditViewController(item: item) makes the data required and immutable. With storyboards, use instantiateViewController(identifier:creator:), or set a property in prepare(for:sender:).
Back through a closure: var onSave: ((Item) -> Void)?. The parent sets it and captures [weak self]. Good for one or two callbacks.
Back through a delegate: a protocol with several named callbacks, held as weak var delegate, so the child does not keep the parent alive.
Shared state: when several screens show the same data, they all observe one model or store instead of passing copies around.
Avoid reaching back through presentingViewController or navigationController?.viewControllers and casting: it ties the child to one specific parent and breaks when the flow changes.
Containment lets one view controller embed another's view inside its own while the child stays a real controller with its own lifecycle. Add it with addChild, add its view, then call didMove(toParent:); remove it in the mirror order.
Why: split a huge screen into reusable pieces (header, list, empty state) or build a custom container (a pager, a loading/content switcher). UINavigationController and UITabBarController are containers built the same way.
Add: addChild(child) → add and constrain child.view → child.didMove(toParent: self).
Remove: child.willMove(toParent: nil) → child.view.removeFromSuperview() → child.removeFromParent().
addChild already calls willMove(toParent:) and removeFromParent already calls didMove(toParent: nil), which is why each sequence names only the other one.
Adding only the view, without the calls, leaves the child without appearance callbacks, trait and safe-area updates, and with navigationController == nil.
UIKit first hit-tests from the window down to find the deepest view under the finger and delivers the touch there; whatever that view does not handle travels up the responder chain: superview, view controller, window, application.
Hit-testing: hitTest(_:with:) checks point(inside:with:) on the view, then asks its subviews from front to back; the deepest view that contains the point wins.
Skipped views: isHidden, alpha below 0.01, or isUserInteractionEnabled == false. The part of a subview outside its superview's bounds cannot be hit, even when it is drawn there because clipsToBounds is off.
Responder chain: UIResponder is the base class of UIView, UIViewController, UIWindow and UIApplication. Unhandled touches, key commands and sendAction(_:to:from:for:) with a nil target move up this chain until someone handles them.
Gesture recognizers see touches before the view. A tap recognizer on a container can swallow a table's row taps unless cancelsTouchesInView = false.
First responder is a different idea: the object that receives keyboard input, set with becomeFirstResponder().
A gesture recognizer is a state machine attached to a view: it watches the touches and calls its action when it recognizes its gesture. By default only one recognizer wins; require(toFail:) sets an order, and the delegate's shouldRecognizeSimultaneouslyWith lets two run together.
Built-in types: tap, long press, pan, swipe, pinch, rotation, screen-edge pan, hover.
Discrete (tap, swipe): .possible → .recognized or .failed; the action fires once.
Continuous (pan, pinch, long press): .began → .changed many times → .ended or .cancelled. Measure translation(in:) against a value saved in .began, and clean up on .cancelled as well as .ended.
single.require(toFail: double): the single tap waits until the double tap fails, a short delay later.
gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:): return true for the scroll view's panGestureRecognizer so your pan and the scroll both work.
UILabel and UIImageView have isUserInteractionEnabled = false by default, so a recognizer added to them never fires until you turn it on.
Every UIView is backed by a CALayer: the view handles touches, layout, the responder chain and accessibility, while the layer holds the content that Core Animation draws and animates. Layers are lighter, but they cannot receive touches.
The view is its layer's delegate. cornerRadius, borderWidth, shadowOpacity and mask are layer properties; backgroundColor and alpha set on the view are forwarded to the layer.
UIView.animate works by adding Core Animation animations to the layer.
During an animation the layer already holds the final value; layer.presentation() returns what is on screen right now, which is useful for hit-testing a moving view.
Use a layer directly for gradients (CAGradientLayer), shapes (CAShapeLayer), video (AVPlayerLayer) and particles (CAEmitterLayer).
Overriding layerClass makes the view's own backing layer that type, so it resizes with the view automatically instead of you syncing a sublayer's frame in layoutSubviews.
setNeedsLayout marks a view for a new layout pass, so layoutSubviews runs; setNeedsDisplay marks its content as stale, so draw(_:) runs to redraw it. Neither acts immediately; both are batched until the end of the current run loop iteration.
Layout changes the positions and sizes of subviews; display redraws the pixels of this one view. Moving a subview never needs setNeedsDisplay.
UIKit calls draw(_:) only: when the view is first shown, after setNeedsDisplay(), and on resize if contentMode == .redraw. Never call it yourself.
Overriding draw(_:) gives the view a backing bitmap of its size times the screen scale, redrawn on the CPU. For shapes that animate or change often, a CAShapeLayer or plain subviews are usually cheaper.
A property that affects drawing calls setNeedsDisplay() in its didSet, so many changes in one pass cost a single redraw.
layoutIfNeeded() forces a pending layout immediately, for example right before an animation.
The main run loop is the main thread's event loop: it handles touches, timers and queued blocks, then commits layout and drawing. A scheduled Timer fires only in .default mode, while scrolling runs the loop in .tracking mode, so the timer waits.
One pass: handle input sources, fire due timers, run blocks queued on DispatchQueue.main, then commit the pending layout and display changes before sleeping. This is why setNeedsLayout batches: every change made in one pass is rendered together.
Modes: the run loop runs in one mode at a time, and only the sources and timers registered for that mode fire. Scrolling switches to .tracking so that nothing else competes with following the finger.
The fix: add the timer for .common, a set that includes both .default and .tracking: RunLoop.main.add(timer, forMode: .common). CADisplayLink follows the same rule.
A scheduled timer is retained by the run loop until invalidate(), and a repeating one keeps its target alive, so invalidate it when the screen goes away.
Long work on the main thread blocks the whole loop: no touches, no timers, no new frames.
Nothing prints at 3 seconds. At 5 seconds the closure runs and prints "closure ran", then releases self, and "deinit" prints. The closure keeps the dismissed controller alive 2 seconds longer, but there is no retain cycle, so it is not a leak.
The closure captures self strongly, and the queue holds the closure until it runs, so the queue indirectly owns the controller. Nothing the controller owns points back at the closure, so the ownership goes one way only.
The extra lifetime still has a cost: work runs for a screen nobody sees (a network call, an analytics event, an alert presented from a controller that is no longer on screen).
With [weak self], "deinit" prints at 3 seconds; at 5 seconds the closure finds self is nil and does nothing.
It becomes a real leak when something that lives as long as the controller keeps it alive and is never stopped: a stored closure property that captures self, a repeating Timer targeting self (the run loop retains it), or a Task with an endless loop that is never cancelled.
Task { } behaves the same way: it holds self until it finishes, so long-running tasks should be cancelled in viewDidDisappear, because deinit cannot run while the task holds self.
The Task captures the cell, not the row. Cells are reused, so when a slow download finishes the cell may already show another user and gets the wrong avatar; the previous image also stays visible until the new one arrives.
Why: dequeueReusableCell returns a cell that has just scrolled off, with its old image still set. Downloads finish in any order, so the last one to finish wins, not the one for the row the cell shows now.
Fix: clear the image when configuring (or in prepareForReuse()), remember which item the cell shows, and check it after await before assigning.
Stronger fix: store the Task on the cell and cancel() it in prepareForReuse(), so off-screen downloads stop as well. Keep the id check: a loader that ignores cancellation still returns an image.
Alternative: never touch the cell from the callback: cache the image by URL, then call reconfigureRows(at:), or reconfigureItems(_:) on a diffable snapshot, so the table asks for whichever cell shows that item now.
Catch it: scroll fast with Network Link Conditioner set to 3G; in a test, give the loader a stub with controllable delays and configure one cell for two rows.