You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
551 lines
17 KiB
551 lines
17 KiB
![]()
2 years ago
|
//
|
||
|
// LTMorphingLabel.swift
|
||
|
//
|
||
|
// The MIT License (MIT)
|
||
|
// Copyright (c) 2017 Lex Tang,
|
||
|
//
|
||
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
||
|
// copy of this software and associated documentation files
|
||
|
// (the “Software”), to deal in the Software without restriction,
|
||
|
// including without limitation the rights to use, copy, modify, merge,
|
||
|
// publish, distribute, sublicense, and/or sell copies of the Software,
|
||
|
// and to permit persons to whom the Software is furnished to do so,
|
||
|
// subject to the following conditions:
|
||
|
//
|
||
|
// The above copyright notice and this permission notice shall be included
|
||
|
// in all copies or substantial portions of the Software.
|
||
|
//
|
||
|
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||
|
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||
|
// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||
|
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||
|
// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||
|
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||
|
//
|
||
|
|
||
|
import Foundation
|
||
|
import UIKit
|
||
|
import QuartzCore
|
||
|
|
||
|
private func < <T : Comparable>(lhs: T?, rhs: T?) -> Bool {
|
||
|
switch (lhs, rhs) {
|
||
|
case let (l?, r?):
|
||
|
return l < r
|
||
|
case (nil, _?):
|
||
|
return true
|
||
|
default:
|
||
|
return false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func >= <T : Comparable>(lhs: T?, rhs: T?) -> Bool {
|
||
|
switch (lhs, rhs) {
|
||
|
case let (l?, r?):
|
||
|
return l >= r
|
||
|
default:
|
||
|
return !(lhs < rhs)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
enum LTMorphingPhases: Int {
|
||
|
case start, appear, disappear, draw, progress, skipFrames
|
||
|
}
|
||
|
|
||
|
typealias LTMorphingStartClosure =
|
||
|
() -> Void
|
||
|
|
||
|
typealias LTMorphingEffectClosure =
|
||
|
(Character, _ index: Int, _ progress: Float) -> LTCharacterLimbo
|
||
|
|
||
|
typealias LTMorphingDrawingClosure =
|
||
|
(LTCharacterLimbo) -> Bool
|
||
|
|
||
|
typealias LTMorphingManipulateProgressClosure =
|
||
|
(_ index: Int, _ progress: Float, _ isNewChar: Bool) -> Float
|
||
|
|
||
|
typealias LTMorphingSkipFramesClosure =
|
||
|
() -> Int
|
||
|
|
||
|
@objc public protocol LTMorphingLabelDelegate {
|
||
|
@objc optional func morphingDidStart(_ label: LTMorphingLabel)
|
||
|
@objc optional func morphingDidComplete(_ label: LTMorphingLabel)
|
||
|
@objc optional func morphingOnProgress(_ label: LTMorphingLabel, progress: Float)
|
||
|
}
|
||
|
|
||
|
// MARK: - LTMorphingLabel
|
||
|
@IBDesignable open class LTMorphingLabel: UILabel {
|
||
|
|
||
|
@IBInspectable open var morphingProgress: Float = 0.0
|
||
|
@IBInspectable open var morphingDuration: Float = 0.6
|
||
|
@IBInspectable open var morphingCharacterDelay: Float = 0.026
|
||
|
@IBInspectable open var morphingEnabled: Bool = true
|
||
|
|
||
|
@IBOutlet open weak var delegate: LTMorphingLabelDelegate?
|
||
|
open var morphingEffect: LTMorphingEffect = .scale
|
||
|
|
||
|
var startClosures = [String: LTMorphingStartClosure]()
|
||
|
var effectClosures = [String: LTMorphingEffectClosure]()
|
||
|
var drawingClosures = [String: LTMorphingDrawingClosure]()
|
||
|
var progressClosures = [String: LTMorphingManipulateProgressClosure]()
|
||
|
var skipFramesClosures = [String: LTMorphingSkipFramesClosure]()
|
||
|
var diffResults: LTStringDiffResult?
|
||
|
var previousText = ""
|
||
|
|
||
|
var currentFrame = 0
|
||
|
var totalFrames = 0
|
||
|
var totalDelayFrames = 0
|
||
|
|
||
|
var totalWidth: Float = 0.0
|
||
|
var previousRects = [CGRect]()
|
||
|
var newRects = [CGRect]()
|
||
|
var charHeight: CGFloat = 0.0
|
||
|
var skipFramesCount: Int = 0
|
||
|
|
||
|
fileprivate var displayLink: CADisplayLink?
|
||
|
|
||
|
private var tempRenderMorphingEnabled = true
|
||
|
|
||
|
#if TARGET_INTERFACE_BUILDER
|
||
|
let presentingInIB = true
|
||
|
#else
|
||
|
let presentingInIB = false
|
||
|
#endif
|
||
|
|
||
|
override open var font: UIFont! {
|
||
|
get {
|
||
|
return super.font ?? UIFont.systemFont(ofSize: 15)
|
||
|
}
|
||
|
set {
|
||
|
super.font = newValue
|
||
|
setNeedsLayout()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override open var text: String? {
|
||
|
get {
|
||
|
return super.text ?? ""
|
||
|
}
|
||
|
set {
|
||
|
guard text != newValue else { return }
|
||
|
|
||
|
previousText = text ?? ""
|
||
|
diffResults = previousText.diffWith(newValue)
|
||
|
super.text = newValue ?? ""
|
||
|
|
||
|
morphingProgress = 0.0
|
||
|
currentFrame = 0
|
||
|
totalFrames = 0
|
||
|
|
||
|
tempRenderMorphingEnabled = morphingEnabled
|
||
|
setNeedsLayout()
|
||
|
|
||
|
if !morphingEnabled {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if presentingInIB {
|
||
|
morphingDuration = 0.01
|
||
|
morphingProgress = 0.5
|
||
|
} else if previousText != text {
|
||
|
start()
|
||
|
let closureKey = "\(morphingEffect.description)\(LTMorphingPhases.start)"
|
||
|
if let closure = startClosures[closureKey] {
|
||
|
return closure()
|
||
|
}
|
||
|
|
||
|
delegate?.morphingDidStart?(self)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
open func start() {
|
||
|
guard displayLink == nil else { return }
|
||
|
displayLink = CADisplayLink(target: self, selector: #selector(displayFrameTick))
|
||
|
displayLink?.add(to: .current, forMode: .common)
|
||
|
}
|
||
|
|
||
|
open func pause() {
|
||
|
displayLink?.isPaused = true
|
||
|
}
|
||
|
|
||
|
open func resume() {
|
||
|
displayLink?.isPaused = false
|
||
|
}
|
||
|
|
||
|
open func finish() {
|
||
|
displayLink?.isPaused = false
|
||
|
}
|
||
|
|
||
|
open func stop() {
|
||
|
displayLink?.remove(from: .current, forMode: .common)
|
||
|
displayLink?.invalidate()
|
||
|
displayLink = nil
|
||
|
}
|
||
|
|
||
|
open var textAttributes: [NSAttributedString.Key: Any]? {
|
||
|
didSet {
|
||
|
setNeedsLayout()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
open override func setNeedsLayout() {
|
||
|
super.setNeedsLayout()
|
||
|
previousRects = rectsOfEachCharacter(previousText, withFont: font)
|
||
|
newRects = rectsOfEachCharacter(text ?? "", withFont: font)
|
||
|
}
|
||
|
|
||
|
override open var bounds: CGRect {
|
||
|
get {
|
||
|
return super.bounds
|
||
|
}
|
||
|
set {
|
||
|
super.bounds = newValue
|
||
|
setNeedsLayout()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override open var frame: CGRect {
|
||
|
get {
|
||
|
return super.frame
|
||
|
}
|
||
|
set {
|
||
|
super.frame = newValue
|
||
|
setNeedsLayout()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
deinit {
|
||
|
stop()
|
||
|
}
|
||
|
|
||
|
lazy var emitterView: LTEmitterView = {
|
||
|
let emitterView = LTEmitterView(frame: self.bounds)
|
||
|
self.addSubview(emitterView)
|
||
|
return emitterView
|
||
|
}()
|
||
|
}
|
||
|
|
||
|
// MARK: - Animation extension
|
||
|
extension LTMorphingLabel {
|
||
|
|
||
|
public func updateProgress(progress: Float) {
|
||
|
guard let displayLink = displayLink else { return }
|
||
|
|
||
|
if displayLink.duration > 0.0 && totalFrames == 0 {
|
||
|
var frameRate = Float(0)
|
||
|
if #available(iOS 10.0, tvOS 10.0, *) {
|
||
|
var frameInterval = 1
|
||
|
if displayLink.preferredFramesPerSecond == 60 {
|
||
|
frameInterval = 1
|
||
|
} else if displayLink.preferredFramesPerSecond == 30 {
|
||
|
frameInterval = 2
|
||
|
} else {
|
||
|
frameInterval = 1
|
||
|
}
|
||
|
frameRate = Float(displayLink.duration) / Float(frameInterval)
|
||
|
} else {
|
||
|
frameRate = Float(displayLink.duration) / Float(displayLink.frameInterval)
|
||
|
}
|
||
|
totalFrames = Int(ceil(morphingDuration / frameRate))
|
||
|
|
||
|
let totalDelay = Float((text!).count) * morphingCharacterDelay
|
||
|
totalDelayFrames = Int(ceil(totalDelay / frameRate))
|
||
|
}
|
||
|
|
||
|
currentFrame = Int(ceil(progress * Float(totalFrames)))
|
||
|
|
||
|
if previousText != text && currentFrame < totalFrames + totalDelayFrames + 10 {
|
||
|
morphingProgress = progress
|
||
|
|
||
|
let closureKey = "\(morphingEffect.description)\(LTMorphingPhases.skipFrames)"
|
||
|
if let closure = skipFramesClosures[closureKey] {
|
||
|
skipFramesCount += 1
|
||
|
if skipFramesCount > closure() {
|
||
|
skipFramesCount = 0
|
||
|
setNeedsDisplay()
|
||
|
}
|
||
|
} else {
|
||
|
setNeedsDisplay()
|
||
|
}
|
||
|
|
||
|
if let onProgress = delegate?.morphingOnProgress {
|
||
|
onProgress(self, morphingProgress)
|
||
|
}
|
||
|
} else {
|
||
|
stop()
|
||
|
|
||
|
delegate?.morphingDidComplete?(self)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@objc func displayFrameTick() {
|
||
|
if totalFrames == 0 {
|
||
|
updateProgress(progress: 0)
|
||
|
} else {
|
||
|
morphingProgress += 1.0 / Float(totalFrames)
|
||
|
updateProgress(progress: morphingProgress)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Could be enhanced by kerning text:
|
||
|
// http://stackoverflow.com/questions/21443625/core-text-calculate-letter-frame-in-ios
|
||
|
func rectsOfEachCharacter(_ textToDraw: String, withFont font: UIFont) -> [CGRect] {
|
||
|
var charRects = [CGRect]()
|
||
|
var leftOffset: CGFloat = 0.0
|
||
|
|
||
|
charHeight = "Leg".size(withAttributes: [.font: font]).height
|
||
|
|
||
|
let topOffset = (bounds.size.height - charHeight) / 2.0
|
||
|
|
||
|
for char in textToDraw {
|
||
|
let charSize = String(char).size(withAttributes: [.font: font])
|
||
|
charRects.append(
|
||
|
CGRect(
|
||
|
origin: CGPoint(
|
||
|
x: leftOffset,
|
||
|
y: topOffset
|
||
|
),
|
||
|
size: charSize
|
||
|
)
|
||
|
)
|
||
|
leftOffset += charSize.width
|
||
|
}
|
||
|
|
||
|
totalWidth = Float(leftOffset)
|
||
|
|
||
|
var stringLeftOffSet: CGFloat = 0.0
|
||
|
|
||
|
switch textAlignment {
|
||
|
case .center:
|
||
|
stringLeftOffSet = CGFloat((Float(bounds.size.width) - totalWidth) / 2.0)
|
||
|
case .right:
|
||
|
stringLeftOffSet = CGFloat(Float(bounds.size.width) - totalWidth)
|
||
|
default:
|
||
|
()
|
||
|
}
|
||
|
|
||
|
var offsetedCharRects = [CGRect]()
|
||
|
|
||
|
for r in charRects {
|
||
|
offsetedCharRects.append(r.offsetBy(dx: stringLeftOffSet, dy: 0.0))
|
||
|
}
|
||
|
|
||
|
return offsetedCharRects
|
||
|
}
|
||
|
|
||
|
func limboOfOriginalCharacter(
|
||
|
_ char: Character,
|
||
|
index: Int,
|
||
|
progress: Float) -> LTCharacterLimbo {
|
||
|
|
||
|
var currentRect = previousRects[index]
|
||
|
let oriX = Float(currentRect.origin.x)
|
||
|
var newX = Float(currentRect.origin.x)
|
||
|
let diffResult = diffResults!.0[index]
|
||
|
var currentFontSize: CGFloat = font.pointSize
|
||
|
var currentAlpha: CGFloat = 1.0
|
||
|
|
||
|
switch diffResult {
|
||
|
// Move the character that exists in the new text to current position
|
||
|
case .same:
|
||
|
newX = Float(newRects[index].origin.x)
|
||
|
currentRect.origin.x = CGFloat(
|
||
|
LTEasing.easeOutQuint(progress, oriX, newX - oriX)
|
||
|
)
|
||
|
case .move(let offset):
|
||
|
newX = Float(newRects[index + offset].origin.x)
|
||
|
currentRect.origin.x = CGFloat(
|
||
|
LTEasing.easeOutQuint(progress, oriX, newX - oriX)
|
||
|
)
|
||
|
case .moveAndAdd(let offset):
|
||
|
newX = Float(newRects[index + offset].origin.x)
|
||
|
currentRect.origin.x = CGFloat(
|
||
|
LTEasing.easeOutQuint(progress, oriX, newX - oriX)
|
||
|
)
|
||
|
default:
|
||
|
// Otherwise, remove it
|
||
|
|
||
|
// Override morphing effect with closure in extenstions
|
||
|
if let closure = effectClosures[
|
||
|
"\(morphingEffect.description)\(LTMorphingPhases.disappear)"
|
||
|
] {
|
||
|
return closure(char, index, progress)
|
||
|
} else {
|
||
|
// And scale it by default
|
||
|
let fontEase = CGFloat(
|
||
|
LTEasing.easeOutQuint(
|
||
|
progress, 0, Float(font.pointSize)
|
||
|
)
|
||
|
)
|
||
|
// For emojis
|
||
|
currentFontSize = max(0.0001, font.pointSize - fontEase)
|
||
|
currentAlpha = CGFloat(1.0 - progress)
|
||
|
currentRect = previousRects[index].offsetBy(
|
||
|
dx: 0,
|
||
|
dy: CGFloat(font.pointSize - currentFontSize)
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return LTCharacterLimbo(
|
||
|
char: char,
|
||
|
rect: currentRect,
|
||
|
alpha: currentAlpha,
|
||
|
size: currentFontSize,
|
||
|
drawingProgress: 0.0
|
||
|
)
|
||
|
}
|
||
|
|
||
|
func limboOfNewCharacter(
|
||
|
_ char: Character,
|
||
|
index: Int,
|
||
|
progress: Float) -> LTCharacterLimbo {
|
||
|
|
||
|
let currentRect = newRects[index]
|
||
|
var currentFontSize = CGFloat(
|
||
|
LTEasing.easeOutQuint(progress, 0, Float(font.pointSize))
|
||
|
)
|
||
|
|
||
|
if let closure = effectClosures[
|
||
|
"\(morphingEffect.description)\(LTMorphingPhases.appear)"
|
||
|
] {
|
||
|
return closure(char, index, progress)
|
||
|
} else {
|
||
|
currentFontSize = CGFloat(
|
||
|
LTEasing.easeOutQuint(progress, 0.0, Float(font.pointSize))
|
||
|
)
|
||
|
// For emojis
|
||
|
currentFontSize = max(0.0001, currentFontSize)
|
||
|
|
||
|
let yOffset = CGFloat(font.pointSize - currentFontSize)
|
||
|
|
||
|
return LTCharacterLimbo(
|
||
|
char: char,
|
||
|
rect: currentRect.offsetBy(dx: 0, dy: yOffset),
|
||
|
alpha: CGFloat(morphingProgress),
|
||
|
size: currentFontSize,
|
||
|
drawingProgress: 0.0
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func limboOfCharacters() -> [LTCharacterLimbo] {
|
||
|
var limbo = [LTCharacterLimbo]()
|
||
|
|
||
|
// Iterate original characters
|
||
|
for (i, character) in previousText.enumerated() {
|
||
|
var progress: Float = 0.0
|
||
|
|
||
|
if let closure = progressClosures[
|
||
|
"\(morphingEffect.description)\(LTMorphingPhases.progress)"
|
||
|
] {
|
||
|
progress = closure(i, morphingProgress, false)
|
||
|
} else {
|
||
|
progress = min(1.0, max(0.0, morphingProgress + morphingCharacterDelay * Float(i)))
|
||
|
}
|
||
|
|
||
|
let limboOfCharacter = limboOfOriginalCharacter(character, index: i, progress: progress)
|
||
|
limbo.append(limboOfCharacter)
|
||
|
}
|
||
|
|
||
|
// Add new characters
|
||
|
for (i, character) in (text!).enumerated() {
|
||
|
if i >= diffResults?.0.count {
|
||
|
break
|
||
|
}
|
||
|
|
||
|
var progress: Float = 0.0
|
||
|
|
||
|
if let closure = progressClosures[
|
||
|
"\(morphingEffect.description)\(LTMorphingPhases.progress)"
|
||
|
] {
|
||
|
progress = closure(i, morphingProgress, true)
|
||
|
} else {
|
||
|
progress = min(1.0, max(0.0, morphingProgress - morphingCharacterDelay * Float(i)))
|
||
|
}
|
||
|
|
||
|
// Don't draw character that already exists
|
||
|
if diffResults?.skipDrawingResults[i] == true {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
if let diffResult = diffResults?.0[i] {
|
||
|
switch diffResult {
|
||
|
case .moveAndAdd, .replace, .add, .delete:
|
||
|
let limboOfCharacter = limboOfNewCharacter(
|
||
|
character,
|
||
|
index: i,
|
||
|
progress: progress
|
||
|
)
|
||
|
limbo.append(limboOfCharacter)
|
||
|
default:
|
||
|
()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return limbo
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
// MARK: - Drawing extension
|
||
|
extension LTMorphingLabel {
|
||
|
|
||
|
override open func didMoveToSuperview() {
|
||
|
guard nil != superview else {
|
||
|
stop()
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if let s = text {
|
||
|
text = s
|
||
|
}
|
||
|
|
||
|
// Load all morphing effects
|
||
|
for effectName: String in LTMorphingEffect.allValues {
|
||
|
let effectFunc = Selector("\(effectName)Load")
|
||
|
if responds(to: effectFunc) {
|
||
|
perform(effectFunc)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override open func drawText(in rect: CGRect) {
|
||
|
if !tempRenderMorphingEnabled || limboOfCharacters().count == 0 {
|
||
|
super.drawText(in: rect)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
for charLimbo in limboOfCharacters() {
|
||
|
let charRect = charLimbo.rect
|
||
|
|
||
|
let willAvoidDefaultDrawing: Bool = {
|
||
|
if let closure = drawingClosures[
|
||
|
"\(morphingEffect.description)\(LTMorphingPhases.draw)"
|
||
|
] {
|
||
|
return closure($0)
|
||
|
}
|
||
|
return false
|
||
|
}(charLimbo)
|
||
|
|
||
|
if !willAvoidDefaultDrawing {
|
||
|
var attrs: [NSAttributedString.Key: Any] = [
|
||
|
.foregroundColor: textColor.withAlphaComponent(charLimbo.alpha)
|
||
|
]
|
||
|
|
||
|
attrs[.font] = UIFont(descriptor: font.fontDescriptor, size: charLimbo.size)
|
||
|
|
||
|
for (key, value) in textAttributes ?? [:] {
|
||
|
attrs[key] = value
|
||
|
}
|
||
|
|
||
|
let s = String(charLimbo.char)
|
||
|
s.draw(in: charRect, withAttributes: attrs)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|