Skip to content

Latest commit

 

History

History
780 lines (602 loc) · 25.9 KB

File metadata and controls

780 lines (602 loc) · 25.9 KB

RunLoop, First Responder и Event Handling в iOS

📋 Содержание

  1. RunLoop
  2. First Responder
  3. Touch Events
  4. Event Handling в UIKit
  5. Связанные темы
  6. Q&A карточки

RunLoop

Что это?

RunLoop – это бесконечный цикл обработки событий в потоке. Он:

  • Обрабатывает события (тачи, таймеры, источники данных)
  • Управляет входящими событиями
  • Переводит поток в режим ожидания, когда событий нет (экономия CPU)

Где используется?

Главный поток (main thread) всегда имеет RunLoop, который:

  • Обрабатывает UI-события
  • Запускает таймеры
  • Обрабатывает асинхронные callbacks
  • Управляет анимациями

Структура RunLoop

// Псевдокод работы RunLoop
while (true) {
    // 1. Получить события из источников
    let event = waitForEvent()
    
    // 2. Обработать событие
    handleEvent(event)
    
    // 3. Если нет событий - спать
    if noEvents {
        sleep()
    }
}

Modes (режимы) RunLoop

RunLoop работает в разных режимах:

// Основные режимы
RunLoop.Mode.default        // Обычный режим (большинство событий)
RunLoop.Mode.tracking       // Режим трекинга (когда скроллишь ScrollView)
RunLoop.Mode.common         // Общие события для обоих режимов

Проблема: Таймер, добавленный в .default, не будет работать во время скролла (.tracking).

Решение:

// ❌ Плохо: не будет работать при скролле
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    print("Tick")
}

// ✅ Хорошо: работает всегда
let timer = Timer(timeInterval: 1.0, repeats: true) { _ in
    print("Tick")
}
RunLoop.current.add(timer, forMode: .common)

Практический пример

class TimerViewController: UIViewController {
    var timer: Timer?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Создаём таймер, который работает даже при скролле
        timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.updateUI()
        }
        RunLoop.current.add(timer!, forMode: .common)
    }
    
    func updateUI() {
        print("Timer tick")
        // Обновление UI
    }
    
    deinit {
        timer?.invalidate()
    }
}

RunLoop и главный поток

// Получить RunLoop главного потока
let mainRunLoop = RunLoop.main

// Получить RunLoop текущего потока
let currentRunLoop = RunLoop.current

// Запустить RunLoop (обычно не нужно для main thread)
RunLoop.current.run()

First Responder

Что это?

First Responder – это объект в Responder Chain, который первым получает право обработать событие (тач, клавиатура, shake и т.д.).

Responder Chain (цепочка респондеров)

События передаются по цепочке:

UIButton → UIView → UIViewController.view → UIViewController → UIWindow → UIApplication → AppDelegate

Если объект не обработал событие, оно передаётся следующему в цепочке.

Схема Responder Chain

┌─────────────┐
│  UIButton   │ ← First Responder (если он активен)
└──────┬──────┘
       │ nextResponder
       ▼
┌─────────────┐
│   UIView    │
└──────┬──────┘
       │ nextResponder
       ▼
┌─────────────┐
│UIViewController│
└──────┬──────┘
       │ nextResponder
       ▼
┌─────────────┐
│  UIWindow   │
└──────┬──────┘
       │ nextResponder
       ▼
┌─────────────┐
│UIApplication│
└──────┬──────┘
       │ nextResponder
       ▼
┌─────────────┐
│ AppDelegate │
└─────────────┘

Примеры использования

1. Клавиатура

class MyTextField: UITextField {
    override func becomeFirstResponder() -> Bool {
        print("Стал first responder - клавиатура появится")
        return super.becomeFirstResponder()
    }
    
    override func resignFirstResponder() -> Bool {
        print("Перестал быть first responder - клавиатура скроется")
        return super.resignFirstResponder()
    }
}

// Использование
textField.becomeFirstResponder()  // Показать клавиатуру
textField.resignFirstResponder()  // Скрыть клавиатуру

// Проверка
if textField.isFirstResponder {
    print("TextField is first responder")
}

2. Обработка Shake событий

class ShakeDetectorView: UIView {
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if motion == .motionShake {
            print("Device shaken!")
            // Показать alert, undo и т.п.
        }
    }
}

class ViewController: UIViewController {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // Стать first responder для получения shake events
        becomeFirstResponder()
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if motion == .motionShake {
            showUndoAlert()
        }
    }
}

3. Кастомные действия (Menu)

class DocumentViewController: UIViewController {
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        becomeFirstResponder()
    }
    
    // Кастомное действие
    @objc func deleteDocument() {
        print("Delete document")
    }
    
    @objc func shareDocument() {
        print("Share document")
    }
    
    // Определяем, какие действия доступны
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        if action == #selector(deleteDocument) || action == #selector(shareDocument) {
            return true
        }
        return super.canPerformAction(action, withSender: sender)
    }
}

4. Скрытие клавиатуры по тапу вне TextField

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Добавляем gesture recognizer для скрытия клавиатуры
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        tapGesture.cancelsTouchesInView = false
        view.addGestureRecognizer(tapGesture)
    }
    
    @objc func dismissKeyboard() {
        view.endEditing(true)
    }
}

Touch Events

Жизненный цикл тача

class CustomView: UIView {
    // 1. Тач начался
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)
        print("Touch began at: \(location)")
    }
    
    // 2. Тач движется
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)
        print("Touch moved to: \(location)")
    }
    
    // 3. Тач закончился
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let location = touch.location(in: self)
        print("Touch ended at: \(location)")
    }
    
    // 4. Тач отменён (например, звонок, системный alert)
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("Touch cancelled")
    }
}

Hit Testing

Как система определяет, какая view должна обработать тач:

class CustomView: UIView {
    // 1. Проверка, попадает ли точка в bounds
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // Кастомная логика: например, расширить область касания
        let expandedBounds = bounds.insetBy(dx: -20, dy: -20)
        return expandedBounds.contains(point)
    }
    
    // 2. Поиск самой глубокой view, которая может обработать тач
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // Проверка: видима ли view
        if isHidden || alpha < 0.01 || !isUserInteractionEnabled {
            return nil
        }
        
        // Проверка: попадает ли точка
        if !self.point(inside: point, with: event) {
            return nil
        }
        
        // Проверка всех subviews (в обратном порядке - сверху вниз)
        for subview in subviews.reversed() {
            let convertedPoint = subview.convert(point, from: self)
            if let hitView = subview.hitTest(convertedPoint, with: event) {
                return hitView
            }
        }
        
        // Если ни одна subview не обработала - возвращаем себя
        return self
    }
}

Пример: кастомная кнопка с расширенной областью касания

class ExpandedButton: UIButton {
    var touchAreaInsets = UIEdgeInsets(top: -20, left: -20, bottom: -20, right: -20)
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let expandedBounds = bounds.inset(by: touchAreaInsets)
        return expandedBounds.contains(point)
    }
}

Множественные касания

class MultiTouchView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        isMultipleTouchEnabled = true // Включить множественные касания
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("Number of touches: \(touches.count)")
        
        for touch in touches {
            let location = touch.location(in: self)
            print("Touch at: \(location)")
        }
    }
}

Event Handling в UIKit

Порядок обработки событий

  1. Hit Testing - система определяет, какая view должна получить событие
  2. Gesture Recognizers - распознаватели жестов могут перехватить событие
  3. Touch Events - если gesture не перехватил, событие попадает в touchesBegan/Moved/Ended
  4. Responder Chain - если view не обработала, событие идёт вверх по цепочке

Gesture Recognizers

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Tap
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        tapGesture.numberOfTapsRequired = 1
        view.addGestureRecognizer(tapGesture)
        
        // Double Tap
        let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
        doubleTapGesture.numberOfTapsRequired = 2
        view.addGestureRecognizer(doubleTapGesture)
        
        // Tap должен ждать double tap
        tapGesture.require(toFail: doubleTapGesture)
        
        // Pan (drag)
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
        view.addGestureRecognizer(panGesture)
        
        // Pinch
        let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
        view.addGestureRecognizer(pinchGesture)
        
        // Long Press
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
        longPressGesture.minimumPressDuration = 0.5
        view.addGestureRecognizer(longPressGesture)
    }
    
    @objc func handleTap(_ gesture: UITapGestureRecognizer) {
        let location = gesture.location(in: view)
        print("Tapped at: \(location)")
    }
    
    @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
        print("Double tapped")
    }
    
    @objc func handlePan(_ gesture: UIPanGestureRecognizer) {
        let translation = gesture.translation(in: view)
        let velocity = gesture.velocity(in: view)
        
        switch gesture.state {
        case .began:
            print("Pan began")
        case .changed:
            print("Panned: \(translation), velocity: \(velocity)")
        case .ended:
            print("Pan ended")
        default:
            break
        }
        
        // Сбросить translation после обработки
        gesture.setTranslation(.zero, in: view)
    }
    
    @objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
        print("Pinch scale: \(gesture.scale), velocity: \(gesture.velocity)")
        
        if gesture.state == .ended {
            gesture.scale = 1.0 // Сброс
        }
    }
    
    @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        if gesture.state == .began {
            print("Long press started")
        }
    }
}

UIGestureRecognizerDelegate

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let button = UIButton()
        button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        view.addSubview(button)
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped))
        tapGesture.delegate = self
        view.addGestureRecognizer(tapGesture)
    }
    
    @objc func buttonTapped() {
        print("Button tapped")
    }
    
    @objc func viewTapped() {
        print("View tapped")
    }
}

extension ViewController: UIGestureRecognizerDelegate {
    // Должен ли gesture начать распознавание
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
    
    // Должен ли gesture получить тач
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        // Не обрабатывать gesture, если тач по кнопке
        return !(touch.view is UIButton)
    }
    
    // Могут ли два gesture работать одновременно
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true // Оба gesture могут распознаваться одновременно
    }
}

Приоритеты gesture recognizers

// Gesture A должен провалиться, чтобы Gesture B начал работу
gestureB.require(toFail: gestureA)

// Пример: single tap ждёт double tap
singleTapGesture.require(toFail: doubleTapGesture)

Связанные темы

CADisplayLink (для анимаций)

class AnimationViewController: UIViewController {
    var displayLink: CADisplayLink?
    var startTime: CFTimeInterval = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Вызывается каждый кадр (60 FPS или 120 FPS на ProMotion)
        displayLink = CADisplayLink(target: self, selector: #selector(update))
        displayLink?.add(to: .current, forMode: .default)
    }
    
    @objc func update(displayLink: CADisplayLink) {
        if startTime == 0 {
            startTime = displayLink.timestamp
        }
        
        let elapsed = displayLink.timestamp - startTime
        print("Frame update at: \(elapsed)s")
        
        // Обновление анимации
        updateAnimation(progress: elapsed)
    }
    
    func updateAnimation(progress: CFTimeInterval) {
        // Кастомная анимация
    }
    
    deinit {
        displayLink?.invalidate()
    }
}

DispatchSource для RunLoop

class FileMonitor {
    private var source: DispatchSourceFileSystemObject?
    
    func monitorFile(at url: URL) {
        let fileHandle = try! FileHandle(forReadingFrom: url)
        
        source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fileHandle.fileDescriptor,
            eventMask: .write,
            queue: .main
        )
        
        source?.setEventHandler { [weak self] in
            print("File changed")
            self?.handleFileChange()
        }
        
        source?.resume()
    }
    
    func handleFileChange() {
        // Обработка изменения файла
    }
}

NotificationCenter и RunLoop

class NotificationManager {
    func setup() {
        // Регистрация на уведомление
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(dataLoaded),
            name: NSNotification.Name("DataLoaded"),
            object: nil
        )
        
        // Keyboard notifications
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillShow),
            name: UIResponder.keyboardWillShowNotification,
            object: nil
        )
    }
    
    @objc func dataLoaded(notification: Notification) {
        print("Data loaded notification received")
        if let data = notification.userInfo?["data"] as? String {
            print("Data: \(data)")
        }
    }
    
    @objc func keyboardWillShow(notification: Notification) {
        if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
            print("Keyboard will show with height: \(keyboardFrame.height)")
        }
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

// Отправка уведомления
NotificationCenter.default.post(
    name: NSNotification.Name("DataLoaded"),
    object: nil,
    userInfo: ["data": "Hello"]
)

Q&A карточки

Карточка 1

Q: Что такое RunLoop?
A: Бесконечный цикл обработки событий в потоке. Обрабатывает UI-события, таймеры, источники данных. Главный поток всегда имеет RunLoop для UI.


Карточка 2

Q: Почему таймер перестаёт работать при скролле UIScrollView?
A: RunLoop переключается из .default в .tracking режим во время скролла. Нужно добавить таймер в .common режим с помощью RunLoop.current.add(timer, forMode: .common).


Карточка 3

Q: Что такое First Responder?
A: Объект в Responder Chain, который первым получает право обработать событие (например, UITextField становится first responder при показе клавиатуры).


Карточка 4

Q: Как работает Responder Chain?
A: События передаются по цепочке: View → ViewController → Window → Application → AppDelegate, пока кто-то не обработает событие или цепочка не закончится.


Карточка 5

Q: Что такое Hit Testing?
A: Процесс определения, какая view должна обработать тач. Система вызывает hitTest(_:with:) и point(inside:with:), проходя по иерархии view сверху вниз (от самой верхней к самой глубокой).


Карточка 6

Q: В каком порядке обрабатываются touch события?
A: touchesBegantouchesMoved (может вызываться много раз) → touchesEnded или touchesCancelled (при прерывании, например звонком).


Карточка 7

Q: Как gesture recognizer взаимодействует с touch events?
A: Gesture recognizer перехватывает события перед touchesBegan/Moved/Ended. Можно контролировать поведение через UIGestureRecognizerDelegate (например, отменить gesture для определённых view).


Карточка 8

Q: Для чего используется CADisplayLink?
A: Для плавных анимаций, синхронизированных с частотой обновления экрана (60 FPS или 120 FPS на ProMotion). Вызывается каждый кадр.


Карточка 9

Q: Как скрыть клавиатуру по тапу вне TextField?
A: Добавить UITapGestureRecognizer на view и вызвать view.endEditing(true) или textField.resignFirstResponder().


Карточка 10

Q: Как обработать shake событие?
A: Переопределить motionEnded(_:with:) в view или view controller, который стал first responder через becomeFirstResponder(). Проверить motion == .motionShake.


Карточка 11

Q: В каком порядке обрабатываются события?
A: 1) Hit Testing определяет целевую view, 2) Gesture Recognizers пытаются перехватить, 3) Touch Events (touchesBegan и т.д.), 4) Responder Chain, если событие не обработано.


Карточка 12

Q: Как расширить область касания кнопки?
A: Переопределить point(inside:with:) и вернуть true для расширенной области:

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

Карточка 13

Q: Как сделать, чтобы два gesture recognizer работали одновременно?
A: Реализовать UIGestureRecognizerDelegate и вернуть true в методе gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:).


Карточка 14

Q: Что делает require(toFail:) для gesture recognizers?
A: Задаёт приоритет: один gesture должен провалиться, чтобы другой начал работу. Например, single tap ждёт, пока не станет ясно, что это не double tap.


Карточка 15

Q: Какие есть RunLoop modes?
A:

  • .default – обычный режим (большинство событий)
  • .tracking – режим трекинга (скролл)
  • .common – псевдо-режим, включающий оба

Checklist для собеседования

  • Понимаю, что такое RunLoop и зачем он нужен
  • Знаю RunLoop modes (.default, .tracking, .common)
  • Понимаю, как работает First Responder
  • Знаю структуру Responder Chain
  • Понимаю процесс Hit Testing
  • Знаю жизненный цикл touch events
  • Умею работать с Gesture Recognizers
  • Понимаю приоритеты gesture recognizers
  • Знаю, как обработать shake событие
  • Понимаю, как работает CADisplayLink

Резюме

  1. RunLoop – цикл обработки событий; главный поток всегда имеет RunLoop
  2. RunLoop Modes.default, .tracking, .common
  3. First Responder – объект, который первым получает событие
  4. Responder Chain – цепочка передачи событий вверх по иерархии
  5. Hit Testing – определение, какая view обработает тач
  6. Touch EventstouchesBegan/Moved/Ended/Cancelled
  7. Gesture Recognizers – высокоуровневая обработка жестов
  8. CADisplayLink – синхронизация с частотой обновления экрана

Помни: RunLoop и Responder Chain – это фундаментальные механизмы iOS для обработки событий!