Signal-Pods/BonMot/Sources/UIKit/TextAlignmentConstraint.swift
2021-08-16 12:12:57 -10:00

245 lines
8.2 KiB
Swift

//
// TextAlignmentConstraint.swift
// BonMot
//
// Created by Cameron Pulsford on 10/4/16.
// Copyright © 2016 Rightpoint. All rights reserved.
//
#if !os(watchOS)
#if os(OSX)
import AppKit
#else
import UIKit
#endif
private var TextAlignmentConstraintKVOContext = "BonMotTextAlignmentConstraintKVOContext" as NSString
/// Used to align various UI controls (anything with a font or attribute text)
/// by properties that are not available with stock constraints:
/// - cap height (the tops of capital letters)
/// - x-height (the height of a lowercase "x")
@objc(BONTextAlignmentConstraint)
public class TextAlignmentConstraint: NSLayoutConstraint {
@objc(BONTextAlignmentConstraintAttribute)
public enum TextAttribute: Int, CustomStringConvertible {
case unspecified
case top
case capHeight
case xHeight
case firstBaseline
case lastBaseline
case bottom
var layoutAttribute: NSLayoutConstraint.Attribute {
switch self {
case .top, .capHeight, .firstBaseline, .xHeight:
return .top
case .lastBaseline, .bottom:
return .bottom
case .unspecified:
return .notAnAttribute
}
}
static private let ibInspectableMapping: [String: TextAttribute] = [
"unspecified": .unspecified,
"top": .top,
"capheight": .capHeight,
"xheight": .xHeight,
"firstbaseline": .firstBaseline,
"lastbaseline": .lastBaseline,
"bottom": .bottom,
]
public var description: String {
switch self {
case .unspecified:
return "unspecified"
case .top:
return "top"
case .capHeight:
return "cap height"
case .xHeight:
return "x-height"
case .firstBaseline:
return "first baseline"
case .lastBaseline:
return "last baseline"
case .bottom:
return "bottom"
}
}
init(ibInspectableString string: String) {
self = TextAttribute.ibInspectableMapping[string] ?? .unspecified
}
}
@IBInspectable public var firstAlignment: String? {
didSet {
firstItemAttribute = TextAttribute(ibInspectableString: firstAlignment?.normalized ?? "")
}
}
@IBInspectable public var secondAlignment: String? {
didSet {
secondItemAttribute = TextAttribute(ibInspectableString: secondAlignment?.normalized ?? "")
}
}
public private(set) var firstItemAttribute: TextAttribute = .unspecified
public private(set) var secondItemAttribute: TextAttribute = .unspecified
private var item1: AnyObject!
private var item2: AnyObject!
// The class part of these selectors are ignored; it is there simply to satisfy Xcode's selector syntax.
private static let fontSelector = #selector(getter: BONTextField.font)
#if os(OSX)
private static let attributedTextSelector = #selector(getter: NSTextField.attributedStringValue)
#else
private static let attributedTextSelector = #selector(getter: UITextField.attributedText)
#endif
/// Construct a new `TextAlignmentConstraint`.
///
/// - Parameters:
/// - view1: The view for the left side of the constraint equation.
/// - attr1: The attribute of the view for the left side of the constraint equation.
/// - relation: The relationship between the left and right side of the constraint equation.
/// - view2: The view for the right side of the constraint equation.
/// - attr2: The attribute of the view for the right side of the constraint equation.
/// - Returns: A constraint object relating the two provided views with the
/// specified relation and attributes.
public static func with(
item view1: AnyObject, attribute attr1: TextAttribute, relatedBy relation: NSLayoutConstraint.Relation, toItem view2: AnyObject, attribute attr2: TextAttribute) -> TextAlignmentConstraint {
let constraint = TextAlignmentConstraint(
item: view1,
attribute: attr1.layoutAttribute,
relatedBy: relation,
toItem: view2,
attribute: attr2.layoutAttribute,
multiplier: 1,
constant: 0)
constraint.item1 = view1
constraint.item2 = view2
constraint.firstItemAttribute = attr1
constraint.secondItemAttribute = attr2
constraint.setupObservers()
constraint.updateConstant()
return constraint
}
deinit {
tearDownObservers()
}
public override func awakeFromNib() {
super.awakeFromNib()
setupObservers()
updateConstant()
}
private func setupObservers() {
for keyPath in fontKeyPaths {
addObserver(self, forKeyPath: keyPath, options: [], context: &TextAlignmentConstraintKVOContext)
}
}
private func tearDownObservers() {
for keyPath in fontKeyPaths {
removeObserver(self, forKeyPath: keyPath, context: &TextAlignmentConstraintKVOContext)
}
}
private var fontKeyPaths: [String] {
let firstItemSelector = #selector(getter: NSLayoutConstraint.firstItem)
let secondItemSelector = #selector(getter: NSLayoutConstraint.secondItem)
return [
"\(firstItemSelector).\(TextAlignmentConstraint.fontSelector)",
"\(firstItemSelector).\(TextAlignmentConstraint.attributedTextSelector)",
"\(secondItemSelector).\(TextAlignmentConstraint.fontSelector)",
"\(secondItemSelector).\(TextAlignmentConstraint.attributedTextSelector)",
]
}
// Can't use block-based KVO until we can use \NSLayoutConstraint.firstItem
// swiftlint:disable:next block_based_kvo
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard context == &TextAlignmentConstraintKVOContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
updateConstant()
}
private func updateConstant() {
#if os(OSX)
let distanceFromTop1 = distanceFromTop(of: firstItem!, with: firstItemAttribute)
#else
let distanceFromTop1 = distanceFromTop(of: firstItem!, with: firstItemAttribute)
#endif
let distanceFromTop2 = distanceFromTop(of: secondItem!, with: secondItemAttribute)
let difference = distanceFromTop2 - distanceFromTop1
constant = difference
}
private func distanceFromTop(of item: AnyObject, with attribute: TextAttribute) -> CGFloat {
guard let font = font(from: item) else {
return 0
}
let topToBaseline = font.ascender
let distanceFromTop: CGFloat
switch attribute {
case .capHeight:
distanceFromTop = topToBaseline - font.capHeight
case .xHeight:
distanceFromTop = topToBaseline - font.xHeight
case .top:
distanceFromTop = 0
case .firstBaseline, .lastBaseline, .bottom:
fatalError("\(attribute) alignment is not currently supported with \(self). Please check https://github.com/Rightpoint/BonMot/issues/37 for progress on this issue.")
case .unspecified:
fatalError("Attempt to reason about unspecified constraint attribute")
}
return distanceFromTop
}
private func font(from item: AnyObject) -> BONFont? {
var font: BONFont?
if item.responds(to: TextAlignmentConstraint.fontSelector) {
font = item.perform(TextAlignmentConstraint.fontSelector).takeUnretainedValue() as? BONFont
}
return font
}
}
private extension String {
var normalized: String {
var n = self.lowercased()
n = n.components(separatedBy: CharacterSet.letters.inverted).joined(separator: "")
n = n.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined(separator: "")
return n
}
}
#endif