Created
November 5, 2018 19:26
-
-
Save shaps80/c17ddb6596bc138e44037c820242553d to your computer and use it in GitHub Desktop.
A convenient controller for working with the responder chain on iOS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
/// Provides a convenient controller for working with the responder chain | |
public final class ResponderController: NSObject, NSCopying { | |
/// When true, the responder will loop through the responders when reaching the ends | |
@IBInspectable | |
public var isContinuous: Bool = true | |
private var responders: [UIResponder] { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
guard let view = view else { return [] } | |
return view.childResponders | |
.sorted { $0.isPositionedBefore($1) } | |
} | |
/// Only responders inside this view will be considered by this controller | |
@IBOutlet public weak var view: UIView? | |
/// Makes a new responder controller | |
@objc public override init() { | |
super.init() | |
} | |
@objc public init(view: UIView) { | |
self.view = view | |
super.init() | |
} | |
public override func copy() -> Any { | |
return ResponderController() | |
} | |
public func copy(with zone: NSZone? = nil) -> Any { | |
return copy() | |
} | |
/// Makes the responder before `currernt` the first responder | |
@IBAction public func previous(_ sender: Any?) { | |
previous?.becomeFirstResponder() | |
} | |
/// Makes the responder after `currernt` the first responder | |
@IBAction public func next(_ sender: Any?) { | |
next?.becomeFirstResponder() | |
} | |
/// Resigns the current first responder | |
@IBAction public func resign(_ sender: Any?) { | |
current?.resignFirstResponder() | |
} | |
/// Makes the first visible control the first responder | |
@IBAction public func begin(_ sender: Any?) { | |
first?.becomeFirstResponder() | |
} | |
} | |
extension ResponderController { | |
/// Returns the first responder | |
@objc public var first: UIResponder? { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
return responders.first | |
} | |
/// Returns the last responder | |
@objc public var last: UIResponder? { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
return responders.last | |
} | |
/// Returns the current responder | |
@objc public var current: UIResponder? { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
guard let index = currentIndex else { return nil } | |
return responders[index] | |
} | |
/// Returns the responder before `current` | |
@objc public var previous: UIResponder? { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
guard let prev = previousIndex else { return nil } | |
return responders[prev] | |
} | |
/// Returns the responder after `current` | |
@objc public var next: UIResponder? { | |
guard Thread.isMainThread else { fatalError("Responder Controller should not be called on a background thread") } | |
guard let next = nextIndex else { return nil } | |
return responders[next] | |
} | |
} | |
private extension ResponderController { | |
var currentIndex: Int? { | |
return responders.index { $0.isFirstResponder } | |
} | |
var previousIndex: Int? { | |
guard let index = currentIndex else { return nil } | |
let responders = self.responders | |
let prev = responders.index(before: index) | |
if prev < responders.startIndex { | |
if isContinuous { | |
return responders.index(before: responders.endIndex) | |
} else { | |
return nil | |
} | |
} | |
return prev | |
} | |
var nextIndex: Int? { | |
guard let index = currentIndex else { return nil } | |
let responders = self.responders | |
let next = responders.index(after: index) | |
if next >= responders.endIndex { | |
if isContinuous { | |
return responders.startIndex | |
} else { | |
return nil | |
} | |
} | |
return next | |
} | |
} | |
private extension UIView { | |
func nearestAncestor(sharedBy otherView: UIView) -> UIView? { | |
let mine = sequence(first: self, next: { $0.superview }) | |
let theirs = Set(sequence(first: otherView, next: { $0.superview })) | |
return mine.first(where: theirs.contains) | |
} | |
func isPositionedBefore(_ view: UIView) -> Bool { | |
guard let ancestor = nearestAncestor(sharedBy: view) else { return false } | |
let lhs = relativeFrame(in: ancestor) | |
let rhs = view.relativeFrame(in: ancestor) | |
switch ancestor.effectiveUserInterfaceLayoutDirection { | |
case .leftToRight: | |
return lhs.maxY < rhs.minY || lhs.maxX < rhs.minX | |
case .rightToLeft: | |
return lhs.maxY < rhs.minY || rhs.maxX < lhs.minX | |
} | |
} | |
} | |
private extension UIView { | |
/// Returns the frame in relation to the specified view. This is essentially the 'visual' frame for this view | |
/// | |
/// - Parameter view: The view to use for frame conversion | |
/// - Returns: A visual frame in `view` | |
func relativeFrame(in view: UIView) -> CGRect { | |
return convert(bounds, to: view) | |
} | |
/// Returns all child responders where `isResponder == true` | |
@objc var childResponders: [UIView] { | |
return [self].filter { $0.isValidResponder } | |
+ subviews.flatMap { $0.childResponders } | |
} | |
/// Returns true if this view is a valid responder, false otherwise | |
/// | |
/// Returns true if `self` satisfies the following requirements: | |
/// 1. canBecomeFirstResponder == true | |
/// 2. alpha > 0 | |
/// 3. isHidden == false | |
@objc var isValidResponder: Bool { | |
return canBecomeFirstResponder | |
&& alpha > 0 | |
&& !isHidden | |
} | |
} | |
private extension UIControl { | |
/// Returns true is this control is a valid responder, false otherwise | |
/// | |
/// Returns true if `self` satisfies the following requirements: | |
/// 1. super.isResponder == true | |
/// 2. isEnabled == true | |
override var isValidResponder: Bool { | |
return super.isValidResponder && isEnabled | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Read about this on my blog and download the Live Playground.
https://shaps.me/blog/2018/11/5/responder-controller