iOS 27 introduces NSTextViewportRenderingSurface and makes UITextView/NSTextView conform to NSTextViewportLayoutControllerDelegate, letting developers override viewport layout delegate methods directly in text view subclasses without building a full custom text view from scratch.
โข UITextView and NSTextView now conform to NSTextViewportLayoutControllerDelegate, allowing subclasses to override willLayout, configureRenderingSurface, and didLayout directly.
โข New NSTextViewportRenderingSurface protocol provides a common abstraction for UIView, NSView, and CALayer as text rendering destinations.
โข New NSTextViewportRenderingSurfaceKey companion protocol lets NSTextLayoutFragment serve as a stable key for caching rendering surfaces across layout cycles.
โข renderingSurfaceFor(_:) method on NSTextViewportLayoutController allows querying the active rendering surface for a given key after layout completes.
โข UITextView and NSTextView now expose viewport layout lifecycle hooks via delegate method overrides, eliminating the need to build a custom text view just to react to paragraph layout events.
โข NSTextViewportRenderingSurface provides a unified abstraction over UIView, NSView, and CALayer as rendering destinations, making it easier to track and cache which views render which layout fragments.
โข Developers can now add rich behaviors like line number gutters, syntax highlighting overlays, and custom decorations on top of framework text views rather than rebuilding text input, selection, accessibility, and undo from scratch.
A UITextView subclass that overrides the new iOS 27 NSTextViewportLayoutControllerDelegate methods to compute and display paragraph line numbers in a gutter view alongside the editor.
import UIKitโ// Pre-iOS 27: you had to set yourself as the viewport controller'sโ// delegate externally โ UITextView did NOT conform toโ// NSTextViewportLayoutControllerDelegate, so subclassing was not enough.+// MARK: - Line-number aware text view (iOS 27+)โfinal class LegacyCodeEditorViewController: UIViewController,โ NSTextViewportLayoutControllerDelegate {โ private var paragraphFrames: [(index: Int, frame: CGRect)] = []+final class LineNumberTextView: UITextView {โ private lazy var textView: UITextView = {โ let tv = UITextView()+ // Paragraph index โ frame in the text view's coordinate space+ private(set) var paragraphFrames: [(index: Int, frame: CGRect)] = []++ // Called once before the viewport layout pass begins+ override func textViewportLayoutControllerWillLayout(+ _ controller: NSTextViewportLayoutController+ ) {+ paragraphFrames.removeAll(keepingCapacity: true)+ }++ // Called for every layout fragment entering the viewport+ override func textViewportLayoutController(+ _ controller: NSTextViewportLayoutController,+ configureRenderingSurface surface: any NSTextViewportRenderingSurface,+ for layoutFragment: NSTextLayoutFragment+ ) {+ // Find the paragraph index by walking the content manager+ guard+ let contentManager = textLayoutManager?.textContentManager,+ let range = layoutFragment.textElement?.elementRange+ else { return }++ var index = 0+ contentManager.enumerateTextElements(from: nil) { element in+ if element.elementRange == range { return false } // stop+ index += 1+ return true+ }++ // Store the layout fragment's frame (in text-view coordinates)+ let frame = CGRect(+ origin: CGPoint(+ x: layoutFragment.layoutFragmentFrame.minX,+ y: layoutFragment.layoutFragmentFrame.minY+ ),+ size: layoutFragment.layoutFragmentFrame.size+ )+ paragraphFrames.append((index: index, frame: frame))+ }++ // Called once after the viewport layout pass finishes+ override func textViewportLayoutControllerDidLayout(+ _ controller: NSTextViewportLayoutController+ ) {+ // Notify any observer (e.g. the gutter view) to redraw+ NotificationCenter.default.post(+ name: .lineNumbersDidUpdate,+ object: self+ )+ }+}++extension Notification.Name {+ static let lineNumbersDidUpdate = Notification.Name("lineNumbersDidUpdate")+}++// MARK: - Minimal gutter view++final class LineNumberGutterView: UIView {+ var paragraphFrames: [(index: Int, frame: CGRect)] = []++ override func draw(_ rect: CGRect) {+ let attrs: [NSAttributedString.Key: Any] = [+ .font: UIFont.monospacedSystemFont(ofSize: 12, weight: .regular),+ .foregroundColor: UIColor.secondaryLabel+ ]+ for entry in paragraphFrames {+ let label = "\(entry.index + 1)"+ let size = label.size(withAttributes: attrs)+ let y = entry.frame.minY + (entry.frame.height - size.height) / 2+ let origin = CGPoint(x: bounds.width - size.width - 4, y: y)+ label.draw(at: origin, withAttributes: attrs)+ }+ }+}++// MARK: - Container view controller++final class CodeEditorViewController: UIViewController {+ private let gutterWidth: CGFloat = 44+ private lazy var textView: LineNumberTextView = {+ let tv = LineNumberTextView()tv.font = .monospacedSystemFont(ofSize: 16, weight: .regular)โ tv.text = "func greet() {\n print(\"Hello!\")\n}\n"+ tv.autocorrectionType = .no+ tv.autocapitalizationType = .none+ tv.text = "func greet() {\n print(\"Hello, iOS 27!\")\n}\n"tv.translatesAutoresizingMaskIntoConstraints = falsereturn tv}()+ private lazy var gutterView: LineNumberGutterView = {+ let v = LineNumberGutterView()+ v.backgroundColor = .systemGroupedBackground+ v.translatesAutoresizingMaskIntoConstraints = false+ return v+ }()override func viewDidLoad() {super.viewDidLoad()+ view.addSubview(gutterView)view.addSubview(textView)NSLayoutConstraint.activate([โ textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),+ gutterView.leadingAnchor.constraint(equalTo: view.leadingAnchor),+ gutterView.topAnchor.constraint(equalTo: view.topAnchor),+ gutterView.bottomAnchor.constraint(equalTo: view.bottomAnchor),+ gutterView.widthAnchor.constraint(equalToConstant: gutterWidth),+ textView.leadingAnchor.constraint(equalTo: gutterView.trailingAnchor),textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),textView.topAnchor.constraint(equalTo: view.topAnchor),textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)])โ // Had to reach into textLayoutManager and steal the delegate โโ // bypassing UITextView's own internal use of it, which was unsupported.โ // In practice this caused layout glitches and was not recommended.โ textView.textLayoutManager?.textViewportLayoutController.delegate = self+ NotificationCenter.default.addObserver(+ self,+ selector: #selector(refreshGutter),+ name: .lineNumbersDidUpdate,+ object: textView+ )}โ // MARK: NSTextViewportLayoutControllerDelegate (manual, fragile)โโ func viewportBounds(for controller: NSTextViewportLayoutController) -> CGRect {โ return textView.bounds+ @objc private func refreshGutter() {+ gutterView.paragraphFrames = textView.paragraphFrames+ gutterView.setNeedsDisplay()}โโ func textViewportLayoutControllerWillLayout(โ _ controller: NSTextViewportLayoutControllerโ ) {โ paragraphFrames.removeAll()โ }โโ func textViewportLayoutController(โ _ controller: NSTextViewportLayoutController,โ configureRenderingSurface surface: Any, // no typed protocol pre-iOS 27โ for layoutFragment: NSTextLayoutFragmentโ ) {โ // No NSTextViewportRenderingSurface protocol existed;โ // surface was untyped and you couldn't safely cast it.โ }โโ func textViewportLayoutControllerDidLayout(โ _ controller: NSTextViewportLayoutControllerโ ) {โ // Trigger gutter redraw โ but layout glitches likelyโ }}
UITextView and NSTextView only recently adopted NSTextViewportLayoutControllerDelegate conformance in the 2027 releases; overriding these delegate methods on older OS versions will silently do nothing. Be careful not to call super in a way that breaks the default layout cycle. Rendering surfaces are cleared at the start of each viewport layout pass, so you must re-assign them in configureRenderingSurface each cycle.
None โ available on all devices running iOS 27
More iOS 27 APIs land every week.
Get notified when new capabilities are published โ no noise, just signal.