-
-
Notifications
You must be signed in to change notification settings - Fork 564
/
Copy pathMarqueeLabel.swift
2096 lines (1717 loc) · 81.9 KB
/
MarqueeLabel.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//
// MarqueeLabel.swift
//
// Created by Charles Powell on 8/6/14.
// Copyright (c) 2015 Charles Powell. All rights reserved.
//
import UIKit
import QuartzCore
@IBDesignable
open class MarqueeLabel: UILabel, CAAnimationDelegate {
/**
An enum that defines the types of `MarqueeLabel` scrolling
- Left: Scrolls left after the specified delay, and does not return to the original position.
- LeftRight: Scrolls left first, then back right to the original position.
- Right: Scrolls right after the specified delay, and does not return to the original position.
- RightLeft: Scrolls right first, then back left to the original position.
- Continuous: Continuously scrolls left (with a pause at the original position if animationDelay is set).
- ContinuousReverse: Continuously scrolls right (with a pause at the original position if animationDelay is set).
*/
public enum MarqueeType: CaseIterable {
case left
case leftRight
case right
case rightLeft
case continuous
case continuousReverse
}
//
// MARK: - Public properties
//
/**
Defines the direction and method in which the `MarqueeLabel` instance scrolls.
`MarqueeLabel` supports six default types of scrolling: `Left`, `LeftRight`, `Right`, `RightLeft`, `Continuous`, and `ContinuousReverse`.
Given the nature of how text direction works, the options for the `type` property require specific text alignments
and will set the textAlignment property accordingly.
- `LeftRight` and `Left` types are ONLY compatible with a label text alignment of `NSTextAlignment.left`.
- `RightLeft` and `Right` types are ONLY compatible with a label text alignment of `NSTextAlignment.right`.
- `Continuous` and `ContinuousReverse` allow the use of `NSTextAlignment.left`, `.right`, or `.center` alignments,
however the text alignment only has an effect when label text is short enough that scrolling is not required.
When scrolling, the labels are effectively center-aligned.
Defaults to `Continuous`.
- Note: Note that any `leadingBuffer` value will affect the text alignment location relative to the frame position,
including with `.center` alignment, where the center alignment location will be shifted left (for `.continuous`) or
right (for `.continuousReverse`) by one-half (1/2) the `.leadingBuffer` amount. Use the `.trailingBuffer` property to
add a buffer between text "loops" without affecting alignment location.
- SeeAlso: textAlignment
- SeeAlso: leadingBuffer
*/
open var type: MarqueeType = .continuous {
didSet {
if type == oldValue {
return
}
updateAndScroll()
}
}
/**
An optional custom scroll "sequence", defined by an array of `ScrollStep` or `FadeStep` instances. A sequence
defines a single scroll/animation loop, which will continue to be automatically repeated like the default types.
A `type` value is still required when using a custom sequence. The `type` value defines the `home` and `away`
values used in the `ScrollStep` instances, and the `type` value determines which way the label will scroll.
When a custom sequence is not supplied, the default sequences are used per the defined `type`.
`ScrollStep` steps are the primary step types, and define the position of the label at a given time in the sequence.
`FadeStep` steps are secondary steps that define the edge fade state (leading, trailing, or both) around the `ScrollStep`
steps.
Defaults to nil.
- Attention: Use of the `scrollSequence` property requires understanding of how MarqueeLabel works for effective
use. As a reference, it is suggested to review the methodology used to build the sequences for the default types.
- SeeAlso: type
- SeeAlso: ScrollStep
- SeeAlso: FadeStep
*/
open var scrollSequence: [MarqueeStep]?
/**
Specifies the animation curve used in the scrolling motion of the labels.
Allowable options:
- `UIViewAnimationOptionCurveEaseInOut`
- `UIViewAnimationOptionCurveEaseIn`
- `UIViewAnimationOptionCurveEaseOut`
- `UIViewAnimationOptionCurveLinear`
Defaults to `UIViewAnimationOptionCurveEaseInOut`.
*/
open var animationCurve: UIView.AnimationCurve = .linear
/**
A boolean property that sets whether the `MarqueeLabel` should behave like a normal `UILabel`.
When set to `true` the `MarqueeLabel` will behave and look like a normal `UILabel`, and will not begin any scrolling animations.
Changes to this property take effect immediately, removing any in-flight animation as well as any edge fade. Note that `MarqueeLabel`
will respect the current values of the `lineBreakMode` and `textAlignment`properties while labelized.
To simply prevent automatic scrolling, use the `holdScrolling` property.
Defaults to `false`.
- SeeAlso: holdScrolling
- SeeAlso: lineBreakMode
- Note: The label will not automatically scroll when this property is set to `true`.
- Warning: The UILabel default setting for the `lineBreakMode` property is `NSLineBreakByTruncatingTail`, which truncates
the text adds an ellipsis glyph (...). Set the `lineBreakMode` property to `NSLineBreakByClipping` in order to avoid the
ellipsis, especially if using an edge transparency fade.
*/
@IBInspectable open var labelize: Bool = false {
didSet {
if labelize != oldValue {
updateAndScroll()
}
}
}
/**
A boolean property that sets whether the `MarqueeLabel` should hold (prevent) automatic label scrolling.
When set to `true`, `MarqueeLabel` will not automatically scroll even its text is larger than the specified frame,
although the specified edge fades will remain.
To set `MarqueeLabel` to act like a normal UILabel, use the `labelize` property.
Defaults to `false`.
- Note: The label will not automatically scroll when this property is set to `true`.
- SeeAlso: labelize
*/
@IBInspectable open var holdScrolling: Bool = false {
didSet {
if holdScrolling != oldValue {
if oldValue == true && !(awayFromHome || labelize ) && labelShouldScroll() {
updateAndScroll()
}
}
}
}
/**
A boolean property that sets whether the `MarqueeLabel` should scroll, even if the specificed test string
can be fully contained within the label frame.
If this property is set to `true`, the `MarqueeLabel` will automatically scroll regardless of text string
length, although this can still be overridden by the `tapToScroll` and `holdScrolling` properties.
Defaults to `false`.
- Warning: Forced scrolling may have unexpected edge cases or have unusual characteristics compared to the
'normal' scrolling feature.
- SeeAlso: holdScrolling
- SeeAlso: tapToScroll
*/
@IBInspectable public var forceScrolling: Bool = false {
didSet {
if forceScrolling != oldValue {
if !(awayFromHome || holdScrolling || tapToScroll ) && labelShouldScroll() {
updateAndScroll()
}
}
}
}
/**
A boolean property that sets whether the `MarqueeLabel` should only begin a scroll when tapped.
If this property is set to `true`, the `MarqueeLabel` will only begin a scroll animation cycle when tapped. The label will
not automatically being a scroll. This setting overrides the setting of the `holdScrolling` property.
Defaults to `false`.
- Note: The label will not automatically scroll when this property is set to `false`.
- SeeAlso: holdScrolling
*/
@IBInspectable open var tapToScroll: Bool = false {
didSet {
if tapToScroll != oldValue {
if tapToScroll {
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(MarqueeLabel.labelWasTapped(_:)))
self.addGestureRecognizer(tapRecognizer)
isUserInteractionEnabled = true
} else {
if let recognizer = self.gestureRecognizers!.first as UIGestureRecognizer? {
self.removeGestureRecognizer(recognizer)
}
isUserInteractionEnabled = false
}
}
}
}
/**
A read-only boolean property that indicates if the label's scroll animation has been paused.
- SeeAlso: pauseLabel
- SeeAlso: unpauseLabel
*/
open var isPaused: Bool {
return (sublabel.layer.speed == 0.0)
}
/**
A boolean property that indicates if the label is currently away from the home location.
The "home" location is the traditional location of `UILabel` text. This property essentially reflects if a scroll animation is underway.
*/
open var awayFromHome: Bool {
if let presentationLayer = sublabel.layer.presentation() {
return !(presentationLayer.position.x == homeLabelFrame.origin.x)
}
return false
}
/**
An optional CGFloat computed value that provides the current scroll animation position, as a value between
0.0 and 1.0. A value of 0.0 indicates the label is "at home" (`awayFromHome` will be false). A value
of 1.0 indicates the label is at the "away" position (and `awayFromHome` will be true).
Will return nil when the label presentation layer is nil.
- Note: For `leftRight` and `rightLeft` type labels this value will increase and reach 1.0 when the label animation reaches the
maximum displacement, as the left or right edge of the label (respectively) is shown. As the scroll reverses,
the value will decrease back to 0.0.
- Note: For `continuous` and`continuousReverse` type labels, this value will increase from 0.0 and reach 1.0 just as the
label loops around and comes to a stop at the original home position. When that position is reached, the value will
jump from 1.0 directly to 0.0 and begin to increase from 0.0 again.
*/
open var animationPosition: CGFloat? {
guard let presentationLayer = sublabel.layer.presentation() else {
return nil
}
// No dividing by zero!
if awayOffset == 0.0 {
return 0.0
}
let progressFraction = abs((presentationLayer.position.x - homeLabelFrame.origin.x) / awayOffset)
return progressFraction
}
/**
The `MarqueeLabel` scrolling speed may be defined by one of two ways:
- Rate(CGFloat): The speed is defined by a rate of motion, in units of points per second.
- Duration(CGFloat): The speed is defined by the time to complete a scrolling animation cycle, in units of seconds.
Each case takes an associated `CGFloat` value, which is the rate/duration desired.
*/
public enum SpeedLimit {
case rate(CGFloat)
case duration(CGFloat)
var value: CGFloat {
switch self {
case .rate(let rate):
return rate
case .duration(let duration):
return duration
}
}
}
/**
Defines the speed of the `MarqueeLabel` scrolling animation.
The speed is set by specifying a case of the `SpeedLimit` enum along with an associated value.
- SeeAlso: SpeedLimit
*/
open var speed: SpeedLimit = .duration(7.0) {
didSet {
switch (speed, oldValue) {
case (.rate(let a), .rate(let b)) where a == b:
return
case (.duration(let a), .duration(let b)) where a == b:
return
default:
updateAndScroll()
}
}
}
@available(*, deprecated, message: "Use speed property instead")
@IBInspectable open var scrollDuration: CGFloat {
get {
switch speed {
case .duration(let duration): return duration
case .rate(_): return 0.0
}
}
set {
speed = .duration(newValue)
}
}
@available(*, deprecated, message : "Use speed property instead")
@IBInspectable open var scrollRate: CGFloat {
get {
switch speed {
case .duration(_): return 0.0
case .rate(let rate): return rate
}
}
set {
speed = .rate(newValue)
}
}
/**
A buffer (offset) between the leading edge of the label text and the label frame.
This property adds additional space between the leading edge of the label text and the label frame. The
leading edge is the edge of the label text facing the direction of scroll (i.e. the edge that animates
offscreen first during scrolling).
Defaults to `0`.
- Note: The value set to this property affects label positioning at all times (including when `labelize` is set to `true`),
including when the text string length is short enough that the label does not need to scroll.
- Note: For Continuous-type labels, the smallest value of `leadingBuffer`, `trailingBuffer`, and `fadeLength`
is used as spacing between the two label instances. Zero is an allowable value for all three properties.
- SeeAlso: trailingBuffer
*/
@IBInspectable open var leadingBuffer: CGFloat = 0.0 {
didSet {
if leadingBuffer != oldValue {
updateAndScroll()
}
}
}
/**
A buffer (offset) between the trailing edge of the label text and the label frame.
This property adds additional space (buffer) between the trailing edge of the label text and the label frame. The
trailing edge is the edge of the label text facing away from the direction of scroll (i.e. the edge that animates
offscreen last during scrolling).
Defaults to `0`.
- Note: The value set to this property has no effect when the `labelize` property is set to `true`.
- Note: For Continuous-type labels, the smallest value of `leadingBuffer`, `trailingBuffer`, and `fadeLength`
is used as spacing between the two label instances. Zero is an allowable value for all three properties.
- SeeAlso: leadingBuffer
*/
@IBInspectable open var trailingBuffer: CGFloat = 0.0 {
didSet {
if trailingBuffer != oldValue {
updateAndScroll()
}
}
}
/**
The length of transparency fade at the left and right edges of the frame.
This propery sets the size (in points) of the view edge transparency fades on the left and right edges of a `MarqueeLabel`. The
transparency fades from an alpha of 1.0 (fully visible) to 0.0 (fully transparent) over this distance. Values set to this property
will be sanitized to prevent a fade length greater than 1/2 of the frame width.
Defaults to `0`.
*/
@IBInspectable open var fadeLength: CGFloat = 0.0 {
didSet {
if fadeLength != oldValue {
applyGradientMask(fadeLength, animated: true)
updateAndScroll()
}
}
}
/**
The length of delay in seconds that the label pauses at the completion of a scroll.
*/
@IBInspectable open var animationDelay: CGFloat = 1.0
/** The read-only/computed duration of the scroll animation (not including delay).
The value of this property is calculated from the value set to the `speed` property. If a duration-type speed is
used to set the label animation speed, `animationDuration` will be equivalent to that value.
*/
public var animationDuration: CGFloat {
switch self.speed {
case .rate(let rate):
return CGFloat(abs(self.awayOffset) / rate)
case .duration(let duration):
return duration
}
}
//
// MARK: - Class Functions and Helpers
//
/**
Convenience method to restart all `MarqueeLabel` instances that have the specified view controller in their next responder chain.
- Parameter controller: The view controller for which to restart all `MarqueeLabel` instances.
- Warning: View controllers that appear with animation (such as from underneath a modal-style controller) can cause some `MarqueeLabel` text
position "jumping" when this method is used in `viewDidAppear` if scroll animations are already underway. Use this method inside `viewWillAppear:`
instead to avoid this problem.
- Warning: This method may not function properly if passed the parent view controller when using view controller containment.
- SeeAlso: restartLabel
- SeeAlso: controllerViewDidAppear:
- SeeAlso: controllerViewWillAppear:
*/
open class func restartLabelsOfController(_ controller: UIViewController) {
MarqueeLabel.notifyController(controller, message: .Restart)
}
/**
Convenience method to restart all `MarqueeLabel` instances that have the specified view controller in their next responder chain.
Alternative to `restartLabelsOfController`. This method is retained for backwards compatibility and future enhancements.
- Parameter controller: The view controller that will appear.
- SeeAlso: restartLabel
- SeeAlso: controllerViewDidAppear
*/
open class func controllerViewWillAppear(_ controller: UIViewController) {
MarqueeLabel.restartLabelsOfController(controller)
}
/**
Convenience method to restart all `MarqueeLabel` instances that have the specified view controller in their next responder chain.
Alternative to `restartLabelsOfController`. This method is retained for backwards compatibility and future enhancements.
- Parameter controller: The view controller that did appear.
- SeeAlso: restartLabel
- SeeAlso: controllerViewWillAppear
*/
open class func controllerViewDidAppear(_ controller: UIViewController) {
MarqueeLabel.restartLabelsOfController(controller)
}
/**
Labelizes all `MarqueeLabel` instances that have the specified view controller in their next responder chain.
The `labelize` property of all recognized `MarqueeLabel` instances will be set to `true`.
- Parameter controller: The view controller for which all `MarqueeLabel` instances should be labelized.
- SeeAlso: labelize
*/
open class func controllerLabelsLabelize(_ controller: UIViewController) {
MarqueeLabel.notifyController(controller, message: .Labelize)
}
/**
De-labelizes all `MarqueeLabel` instances that have the specified view controller in their next responder chain.
The `labelize` property of all recognized `MarqueeLabel` instances will be set to `false`.
- Parameter controller: The view controller for which all `MarqueeLabel` instances should be de-labelized.
- SeeAlso: labelize
*/
open class func controllerLabelsAnimate(_ controller: UIViewController) {
MarqueeLabel.notifyController(controller, message: .Animate)
}
//
// MARK: - Initialization
//
/**
Returns a newly initialized `MarqueeLabel` instance with the specified scroll rate and edge transparency fade length.
- Parameter frame: A rectangle specifying the initial location and size of the view in its superview's coordinates. Text (for the given font, font size, etc.) that does not fit in this frame will automatically scroll.
- Parameter pixelsPerSec: A rate of scroll for the label scroll animation. Must be non-zero. Note that this will be the peak (mid-transition) rate for ease-type animation.
- Parameter fadeLength: A length of transparency fade at the left and right edges of the `MarqueeLabel` instance's frame.
- Returns: An initialized `MarqueeLabel` object or nil if the object couldn't be created.
- SeeAlso: fadeLength
*/
public init(frame: CGRect, rate: CGFloat, fadeLength fade: CGFloat) {
speed = .rate(rate)
fadeLength = CGFloat(min(fade, frame.size.width/2.0))
super.init(frame: frame)
setup()
}
/**
Returns a newly initialized `MarqueeLabel` instance with the specified scroll rate and edge transparency fade length.
- Parameter frame: A rectangle specifying the initial location and size of the view in its superview's coordinates. Text (for the given font, font size, etc.) that does not fit in this frame will automatically scroll.
- Parameter scrollDuration: A scroll duration the label scroll animation. Must be non-zero. This will be the duration that the animation takes for one-half of the scroll cycle in the case of left-right and right-left marquee types, and for one loop of a continuous marquee type.
- Parameter fadeLength: A length of transparency fade at the left and right edges of the `MarqueeLabel` instance's frame.
- Returns: An initialized `MarqueeLabel` object or nil if the object couldn't be created.
- SeeAlso: fadeLength
*/
public init(frame: CGRect, duration: CGFloat, fadeLength fade: CGFloat) {
speed = .duration(duration)
fadeLength = CGFloat(min(fade, frame.size.width/2.0))
super.init(frame: frame)
setup()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
/**
Returns a newly initialized `MarqueeLabel` instance.
The default scroll duration of 7.0 seconds and fade length of 0.0 are used.
- Parameter frame: A rectangle specifying the initial location and size of the view in its superview's coordinates. Text (for the given font, font size, etc.) that does not fit in this frame will automatically scroll.
- Returns: An initialized `MarqueeLabel` object or nil if the object couldn't be created.
*/
convenience public override init(frame: CGRect) {
self.init(frame: frame, duration:7.0, fadeLength:0.0)
}
private func setup() {
// Create sublabel
sublabel = UILabel(frame: self.bounds)
sublabel.tag = 700
sublabel.layer.anchorPoint = CGPoint.zero
// Add sublabel
addSubview(sublabel)
// Configure self
super.clipsToBounds = true
super.numberOfLines = 1
// Add notification observers
// Custom class notifications
NotificationCenter.default.addObserver(self, selector: #selector(MarqueeLabel.restartForViewController(_:)), name: NSNotification.Name(rawValue: MarqueeKeys.Restart.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(MarqueeLabel.labelizeForController(_:)), name: NSNotification.Name(rawValue: MarqueeKeys.Labelize.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(MarqueeLabel.animateForController(_:)), name: NSNotification.Name(rawValue: MarqueeKeys.Animate.rawValue), object: nil)
// UIApplication state notifications
NotificationCenter.default.addObserver(self, selector: #selector(MarqueeLabel.restartLabel), name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(MarqueeLabel.shutdownLabel), name: UIApplication.didEnterBackgroundNotification, object: nil)
}
// Interface Builder features deprecated in visionOS
#if !os(visionOS)
override open func awakeFromNib() {
super.awakeFromNib()
forwardPropertiesToSublabel()
}
@available(iOS 8.0, *)
override open func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
forwardPropertiesToSublabel()
}
#endif
private func forwardPropertiesToSublabel() {
/*
Note that this method is currently ONLY called from awakeFromNib, i.e. when
text properties are set via a Storyboard. As the Storyboard/IB doesn't currently
support attributed strings, there's no need to "forward" the super attributedString value.
*/
// Since we're a UILabel, we actually do implement all of UILabel's properties.
// We don't care about these values, we just want to forward them on to our sublabel.
let properties = ["baselineAdjustment", "enabled", "highlighted", "highlightedTextColor",
"minimumFontSize", "shadowOffset", "textAlignment",
"userInteractionEnabled", "adjustsFontSizeToFitWidth", "minimumScaleFactor",
"lineBreakMode", "numberOfLines", "contentMode"]
// Iterate through properties
sublabel.text = super.text
sublabel.font = super.font
sublabel.textColor = super.textColor
sublabel.backgroundColor = super.backgroundColor ?? UIColor.clear
sublabel.shadowColor = super.shadowColor
sublabel.shadowOffset = super.shadowOffset
for prop in properties {
let value = super.value(forKey: prop)
sublabel.setValue(value, forKeyPath: prop)
}
}
//
// MARK: - MarqueeLabel Heavy Lifting
//
override open func layoutSubviews() {
super.layoutSubviews()
updateAndScroll()
}
override open func willMove(toWindow newWindow: UIWindow?) {
if newWindow == nil {
shutdownLabel()
}
}
override open func didMoveToWindow() {
if self.window == nil {
shutdownLabel()
} else {
updateAndScroll()
}
}
private func updateAndScroll() {
// Do not automatically begin scroll if tapToScroll is true
updateAndScroll(overrideHold: false)
}
private func updateAndScroll(overrideHold: Bool) {
// Check if scrolling can occur
if !labelReadyForScroll() {
return
}
// Calculate expected size
let expectedLabelSize = sublabel.desiredSize()
// Invalidate intrinsic size
invalidateIntrinsicContentSize()
// Move label to home
returnLabelToHome()
// Check if label should scroll
// Note that the holdScrolling propery does not affect this
if !labelShouldScroll() {
// Set text alignment and break mode to act like a normal label
sublabel.textAlignment = super.textAlignment
sublabel.lineBreakMode = super.lineBreakMode
sublabel.adjustsFontSizeToFitWidth = super.adjustsFontSizeToFitWidth
sublabel.minimumScaleFactor = super.minimumScaleFactor
let labelFrame: CGRect
switch type {
case .continuousReverse, .rightLeft:
labelFrame = bounds.divided(atDistance: leadingBuffer, from: CGRectEdge.maxXEdge).remainder.integral
default:
labelFrame = CGRect(x: leadingBuffer, y: 0.0, width: bounds.size.width - leadingBuffer, height: bounds.size.height).integral
}
homeLabelFrame = labelFrame
awayOffset = 0.0
// Remove any additional sublabels (for continuous types)
repliLayer?.instanceCount = 1
// Set the sublabel frame to calculated labelFrame
sublabel.frame = labelFrame
// Remove fade, as by definition none is needed in this case
removeGradientMask()
return
}
// Label DOES need to scroll
// Reset font scaling to off for scrolling
sublabel.adjustsFontSizeToFitWidth = false
sublabel.minimumScaleFactor = 0.0
// Spacing between primary and second sublabel must be at least equal to leadingBuffer, and at least equal to the fadeLength
let minTrailing = minimumTrailingDistance
// Determine positions and generate scroll steps
let sequence: [MarqueeStep]
switch type {
case .continuous, .continuousReverse:
if type == .continuous {
homeLabelFrame = CGRect(x: leadingBuffer, y: 0.0, width: expectedLabelSize.width, height: bounds.size.height).integral
awayOffset = -(homeLabelFrame.size.width + minTrailing)
} else { // .ContinuousReverse
homeLabelFrame = CGRect(x: bounds.size.width - (expectedLabelSize.width + leadingBuffer), y: 0.0, width: expectedLabelSize.width, height: bounds.size.height).integral
awayOffset = (homeLabelFrame.size.width + minTrailing)
}
// Find when the lead label will be totally offscreen
let offsetDistance = awayOffset
let offscreenAmount = homeLabelFrame.size.width
let startFadeFraction = abs(offscreenAmount / offsetDistance)
// Find when the animation will hit that point
let startFadeTimeFraction = timingFunctionForAnimationCurve(animationCurve).durationPercentageForPositionPercentage(startFadeFraction, duration: (animationDelay + animationDuration))
let startFadeTime = startFadeTimeFraction * animationDuration
sequence = scrollSequence ?? [
ScrollStep(timeStep: 0.0, position: .home, edgeFades: .trailing), // Starting point, at home, with trailing fade
ScrollStep(timeStep: animationDelay, position: .home, edgeFades: .trailing), // Delay at home, maintaining fade state
FadeStep(timeStep: 0.2, edgeFades: [.leading, .trailing]), // 0.2 sec after scroll start, fade leading edge in as well
FadeStep(timeStep: (startFadeTime - animationDuration), // Maintain fade state until just before reaching end of scroll animation
edgeFades: [.leading, .trailing]),
ScrollStep(timeStep: animationDuration, timingFunction: animationCurve, // Ending point (back at home), with animationCurve transition, with trailing fade
position: .away, edgeFades: .trailing)
]
// Set frame and text
sublabel.frame = homeLabelFrame
// Configure replication
// Determine replication count required
let fitFactor: CGFloat = bounds.size.width/(expectedLabelSize.width + leadingBuffer)
let repliCount = 1 + Int(ceil(fitFactor))
repliLayer?.instanceCount = repliCount
repliLayer?.instanceTransform = CATransform3DMakeTranslation(-awayOffset, 0.0, 0.0)
case .leftRight, .left, .rightLeft, .right:
if type == .leftRight || type == .left {
homeLabelFrame = CGRect(x: leadingBuffer, y: 0.0, width: expectedLabelSize.width, height: bounds.size.height).integral
awayOffset = bounds.size.width - (expectedLabelSize.width + leadingBuffer + trailingBuffer)
// Enforce text alignment for this type
sublabel.textAlignment = NSTextAlignment.left
} else {
homeLabelFrame = CGRect(x: bounds.size.width - (expectedLabelSize.width + leadingBuffer), y: 0.0, width: expectedLabelSize.width, height: bounds.size.height).integral
awayOffset = (expectedLabelSize.width + trailingBuffer + leadingBuffer) - bounds.size.width
// Enforce text alignment for this type
sublabel.textAlignment = NSTextAlignment.right
}
// Set frame and text
sublabel.frame = homeLabelFrame
// Remove any replication
repliLayer?.instanceCount = 1
if type == .leftRight || type == .rightLeft {
sequence = scrollSequence ?? [
ScrollStep(timeStep: 0.0, position: .home, edgeFades: .trailing), // Starting point, at home, with trailing fade
ScrollStep(timeStep: animationDelay, position: .home, edgeFades: .trailing), // Delay at home, maintaining fade state
FadeStep(timeStep: 0.2, edgeFades: [.leading, .trailing]), // 0.2 sec after delay ends, fade leading edge in as well
FadeStep(timeStep: -0.2, edgeFades: [.leading, .trailing]), // Maintain fade state until 0.2 sec before reaching away position
ScrollStep(timeStep: animationDuration, timingFunction: animationCurve, // Away position, using animationCurve transition, with only leading edge faded in
position: .away, edgeFades: .leading),
ScrollStep(timeStep: animationDelay, position: .away, edgeFades: .leading), // Delay at away, maintaining fade state (leading only)
FadeStep(timeStep: 0.2, edgeFades: [.leading, .trailing]), // 0.2 sec after delay ends, fade trailing edge back in as well
FadeStep(timeStep: -0.2, edgeFades: [.leading, .trailing]), // Maintain fade state until 0.2 sec before reaching home position
ScrollStep(timeStep: animationDuration, timingFunction: animationCurve, // Ending point, back at home, with only trailing fade
position: .home, edgeFades: .trailing)
]
} else { // .left or .right
sequence = scrollSequence ?? [
ScrollStep(timeStep: 0.0, position: .home, edgeFades: .trailing), // Starting point, at home, with trailing fade
ScrollStep(timeStep: animationDelay, position: .home, edgeFades: .trailing), // Delay at home, maintaining fade state
FadeStep(timeStep: 0.2, edgeFades: [.leading, .trailing]), // 0.2 sec after delay ends, fade leading edge in as well
FadeStep(timeStep: -0.2, edgeFades: [.leading, .trailing]), // Maintain fade state until 0.2 sec before reaching away position
ScrollStep(timeStep: animationDuration, timingFunction: animationCurve, // Away position, using animationCurve transition, with only leading edge faded in
position: .away, edgeFades: .leading),
ScrollStep(timeStep: animationDelay, position: .away, edgeFades: .leading), // "Delay" at away, maintaining fade state
]
}
}
// Configure gradient for current condition
applyGradientMask(fadeLength, animated: !self.labelize)
if overrideHold || (!holdScrolling && !overrideHold) {
beginScroll(sequence)
}
}
override open func sizeThatFits(_ size: CGSize) -> CGSize {
return sizeThatFits(size, withBuffers: true)
}
open func sizeThatFits(_ size: CGSize, withBuffers: Bool) -> CGSize {
var fitSize = sublabel.sizeThatFits(size)
if withBuffers {
fitSize.width += leadingBuffer
}
return fitSize
}
/**
Returns the unconstrained size of the specified label text (for a single line).
*/
open func textLayoutSize() -> CGSize {
return sublabel.desiredSize()
}
//
// MARK: - Animation Handling
//
open func labelShouldScroll() -> Bool {
// Check for nil string
guard sublabel.text != nil else {
return false
}
// Check for empty string
guard !sublabel.text!.isEmpty else {
return false
}
var labelTooLarge = false
if !super.adjustsFontSizeToFitWidth {
// Usual logic to check if the label string fits
labelTooLarge = (sublabel.desiredSize().width + leadingBuffer) > self.bounds.size.width + CGFloat.ulpOfOne
} else {
// Logic with auto-scale support
// Create mutable attributed string to modify font sizes in-situ
let resizedString = NSMutableAttributedString.init(attributedString: sublabel.attributedText!)
resizedString.beginEditing()
// Enumerate all font attributes of attributed string
resizedString.enumerateAttribute(.font, in: NSRange(0..<sublabel.attributedText!.length)) { val, rng, stop in
if let originalFont = val as? UIFont {
// Calculate minimum-factor font size
let resizedFontSize = originalFont.pointSize * super.minimumScaleFactor
// Create and apply new font attribute to string
if let resizedFont = UIFont.init(name: originalFont.fontName, size: resizedFontSize) {
resizedString.addAttribute(.font, value: resizedFont, range: rng)
}
}
}
resizedString.endEditing()
// Get new expected minimum size
let expectedMinimumTextSize = resizedString.size()
// If even after shrinking it's too wide, consider the label too large and in need of scrolling
labelTooLarge = self.bounds.size.width < ceil(expectedMinimumTextSize.width) + CGFloat.ulpOfOne
// Set scale factor on sublabel dependent on result, back to 1.0 if too big to prevent
// sublabel from shrinking AND scrolling
sublabel.minimumScaleFactor = labelTooLarge ? 1.0 : super.minimumScaleFactor
}
let animationHasDuration = speed.value > 0.0
return (!labelize && (forceScrolling || labelTooLarge) && animationHasDuration)
}
private func labelReadyForScroll() -> Bool {
// Check if we have a superview
if superview == nil {
return false
}
// Check if we are attached to a window
if window == nil {
return false
}
// Check if our view controller is ready
let viewController = firstAvailableViewController()
if viewController != nil {
if !viewController!.isViewLoaded {
return false
}
}
return true
}
private func returnLabelToHome() {
// Store if label is away from home at time of call
let away = awayFromHome
// Remove any gradient animation
maskLayer?.removeAllAnimations()
// Remove all sublabel position animations
sublabel.layer.removeAllAnimations()
// Fire completion block if appropriate
if away {
// If label was away when this was called, animation did NOT finish
scrollCompletionBlock?(!away)
}
// Remove completion block
scrollCompletionBlock = nil
}
private func beginScroll(_ sequence: [MarqueeStep]) {
let scroller = generateScrollAnimation(sequence)
let fader = generateGradientAnimation(sequence, totalDuration: scroller.duration)
scroll(scroller, fader: fader)
}
private func scroll(_ scroller: MLAnimation, fader: MLAnimation?) {
// Check for conditions which would prevent scrolling
if !labelReadyForScroll() {
return
}
// Convert fader to var
var fader = fader
// Call pre-animation hook
labelWillBeginScroll()
// Start animation transactions
CATransaction.begin()
CATransaction.setAnimationDuration(TimeInterval(scroller.duration))
// Create gradient animation, if needed
let gradientAnimation: CAKeyframeAnimation?
// Check for IBDesignable
#if !TARGET_INTERFACE_BUILDER
if fadeLength > 0.0 {
// Remove any setup animation, but apply final values
if let setupAnim = maskLayer?.animation(forKey: "setupFade") as? CABasicAnimation, let finalColors = setupAnim.toValue as? [CGColor] {
maskLayer?.colors = finalColors
}
maskLayer?.removeAnimation(forKey: "setupFade")
// Generate animation if needed
if let previousAnimation = fader?.anim {
gradientAnimation = previousAnimation
} else {
gradientAnimation = nil
}
// Apply fade animation
maskLayer?.add(gradientAnimation!, forKey: "gradient")
} else {
// No animation needed
fader = nil
}
#else
fader = nil
#endif
scrollCompletionBlock = { [weak self] (finished: Bool) in
guard self != nil else {
return
}
// Call returned home function
self!.labelReturnedToHome(finished)
// Check to ensure that:
// 1) The instance is still attached to a window - this completion block is called for
// many reasons, including if the animation is removed due to the view being removed
// from the UIWindow (typically when the view controller is no longer the "top" view)
guard self!.window != nil else {
return
}
// 2) We don't double fire if an animation already exists
guard self!.sublabel.layer.animation(forKey: "position") == nil else {
return
}
// 3) We don't start automatically if the animation was unexpectedly interrupted
guard finished else {
// Do not continue into the next loop
return
}
// 4) A completion block still exists for the NEXT loop. A notable case here is if
// returnLabelToHome() was called during a subclass's labelReturnToHome() function
guard self!.scrollCompletionBlock != nil else {
return
}
// Begin again, if conditions met
if self!.labelShouldScroll() && !self!.tapToScroll && !self!.holdScrolling {
// Perform completion callback
self!.scroll(scroller, fader: fader)
}
}
// Perform scroll animation
scroller.anim.setValue(true, forKey: MarqueeKeys.CompletionClosure.rawValue)
scroller.anim.delegate = self
if type == .left || type == .right {
// Make it stay at away permanently
scroller.anim.isRemovedOnCompletion = false
scroller.anim.fillMode = .forwards
}
sublabel.layer.add(scroller.anim, forKey: "position")
CATransaction.commit()
}