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.
 
 
 

550 lines
17 KiB

//
// 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)
}
}
}
}