feat: skeletons api

This commit is contained in:
Nikita Semenov 2023-03-01 19:05:52 +03:00
parent e9b32ce326
commit 9f5d7387d7
35 changed files with 1024 additions and 21 deletions

View File

@ -1,5 +1,9 @@
# Changelog
### 1.36.0
- **Added**: API for converting view hierarchy to skeletons
### 1.35.1
- **Added**: Auto documentation generation for `TIFoundationUtils` playground and compile checks for playground before release

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIAppleMapUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Apple MapKit.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIAuth'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Login, registration, confirmation and other related actions'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIDeveloperUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Universal web view API'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIEcommerce'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Cart, products, promocodes, bonuses and other related actions'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIFoundationUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for Foundation framework classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIGoogleMapUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Google Maps SDK.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIKeychainUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for Keychain classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TILogging'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Logging API'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIMapUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for map objects clustering and interacting.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIMoyaNetworking'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Moya + Swagger network service.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TINetworking'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Swagger-frendly networking layer helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TINetworkingCache'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Caching results of EndpointRequests.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIPagination'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Generic pagination component.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TISwiftUICore'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Core UI elements: protocols, views and helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TISwiftUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Bunch of useful helpers for Swift development.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITableKitUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for TableKit classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -0,0 +1,35 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 QuartzCore
open class BaseSkeletonsAnimationConfiguration {
public var duration: CFTimeInterval
public var timingFunction: CAMediaTimingFunction?
public init(duration: CFTimeInterval = 1, timingFunction: CAMediaTimingFunction? = nil) {
self.duration = duration
self.timingFunction = timingFunction
}
}

View File

@ -0,0 +1,37 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 QuartzCore
open class DirectionalSkeletonsAnimationConfiguration: BaseSkeletonsAnimationConfiguration {
public var direction: SkeletonsAnimationDirection
public init(direction: SkeletonsAnimationDirection = .leftToRight,
duration: CFTimeInterval = 1,
timingFunction: CAMediaTimingFunction? = nil) {
self.direction = direction
super.init(duration: duration, timingFunction: timingFunction)
}
}

View File

@ -0,0 +1,45 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 QuartzCore
open class SkeletonsAnimationBuilder {
public static func createDirectionalGradientAnimation(_ conf: DirectionalSkeletonsAnimationConfiguration) -> CAAnimationGroup {
let startPointAnimation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.startPoint))
startPointAnimation.fromValue = conf.direction.startPoint.from
startPointAnimation.toValue = conf.direction.startPoint.to
let endPointAnimation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.endPoint))
endPointAnimation.fromValue = conf.direction.endPoint.from
endPointAnimation.toValue = conf.direction.endPoint.to
let animationGroup = CAAnimationGroup()
animationGroup.timingFunction = conf.timingFunction
animationGroup.duration = conf.duration
animationGroup.animations = [startPointAnimation, endPointAnimation]
animationGroup.repeatCount = .infinity
return animationGroup
}
}

View File

@ -0,0 +1,92 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 struct CoreGraphics.CGPoint
typealias GradientAnimationAnchorPoints = (from: CGPoint, to: CGPoint)
public enum SkeletonsAnimationDirection {
case leftToRight
case rightToLeft
case topToBottom
case bottomToTop
case topLeftToBottomRight
case topRightToBottomLeft
case bottomLeftToTopRight
case bottomRightToTopLeft
var startPoint: GradientAnimationAnchorPoints {
switch self {
case .leftToRight:
return (from: CGPoint(x: -1, y: 0.5), to: CGPoint(x: 1, y: 0.5))
case .rightToLeft:
return (from: Self.leftToRight.startPoint.to, to: Self.leftToRight.startPoint.from)
case .topToBottom:
return (from: CGPoint(x: 0.5, y: -1), to: CGPoint(x: 0.5, y: 1))
case .bottomToTop:
return (from: Self.topToBottom.startPoint.to, to: Self.topToBottom.startPoint.from)
case .topLeftToBottomRight:
return (from: CGPoint(x: -1, y: -1), to: CGPoint(x: 1, y: 1))
case .topRightToBottomLeft:
return (from: Self.bottomLeftToTopRight.startPoint.to, to: Self.bottomLeftToTopRight.startPoint.from)
case .bottomLeftToTopRight:
return (from: CGPoint(x: -1, y: 2), to: CGPoint(x: 1, y: 0))
case .bottomRightToTopLeft:
return (from: Self.topLeftToBottomRight.startPoint.to, to: Self.topLeftToBottomRight.startPoint.from)
}
}
var endPoint: GradientAnimationAnchorPoints {
switch self {
case .leftToRight:
return (from: CGPoint(x: 0, y: 0.5), to: CGPoint(x: 2, y: 0.5))
case .rightToLeft:
return (from: Self.leftToRight.endPoint.to, to: Self.leftToRight.endPoint.from)
case .topToBottom:
return (from: CGPoint(x: 0.5, y: 0), to: CGPoint(x: 0.5, y: 2))
case .bottomToTop:
return (from: Self.topToBottom.endPoint.to, to: Self.topToBottom.endPoint.from)
case .topLeftToBottomRight:
return (from: CGPoint(x: 0, y: 0), to: CGPoint(x: 2, y: 2))
case .topRightToBottomLeft:
return (from: Self.bottomLeftToTopRight.endPoint.to, to: Self.bottomLeftToTopRight.endPoint.from)
case .bottomLeftToTopRight:
return (from: CGPoint(x: 0, y: 1), to: CGPoint(x: 2, y: -1))
case .bottomRightToTopLeft:
return (from: Self.topLeftToBottomRight.endPoint.to, to: Self.topLeftToBottomRight.endPoint.from)
}
}
}

View File

@ -0,0 +1,52 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 UIKit
open class BaseViewSkeletonsConfiguration {
public enum Shape {
case rectangle(cornerRadius: CGFloat)
case circle
case custom(CGPath)
}
public var shape: Shape
public init(shape: Shape = .rectangle(cornerRadius: .zero)) {
self.shape = shape
}
open func drawPath(rect: CGRect) -> CGPath {
switch shape {
case let .custom(path):
return path
case let .rectangle(cornerRadius: cornerRadius):
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
return path.cgPath
case .circle:
return CGPath(ellipseIn: rect, transform: nil)
}
}
}

View File

@ -0,0 +1,163 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 TISwiftUtils
import UIKit
open class LabelSkeletonsConfiguration: BaseViewSkeletonsConfiguration {
public enum LinesAmount {
case constant(Int)
case asLabelSize
case asLabelNumberOfLines
}
private var isMultiline = false
private var labelNumberOfLines: Int = .zero
private var labelHeight: CGFloat = .zero
private var font: UIFont?
public var numberOfLines: LinesAmount
public var lineHeight: Closure<UIFont?, CGFloat>?
public var lineSpacing: Closure<UIFont?, CGFloat>?
public init(numberOfLines: LinesAmount = .constant(3),
lineHeight: Closure<UIFont?, CGFloat>? = nil,
lineSpacing: Closure<UIFont?, CGFloat>? = nil,
shape: Shape = .rectangle(cornerRadius: .zero)) {
self.numberOfLines = numberOfLines
self.lineHeight = lineHeight
self.lineSpacing = lineSpacing
super.init(shape: shape)
}
open override func drawPath(rect: CGRect) -> CGPath {
/*
SkeletonLayer
|-------------------------|
||-----------------------|| - first line CGRect(0, 0, rect.width, lineHeight)
| | - spacing
||-----------------------|| - second line CGRect(0, lineHeight + spacing, rect.width, lineHeight)
| | - spacing
||-----------------------|| - third line CGRect(0, (lineHeight + spacing) * 2, rect.width, lineHeight)
|-------------------------|
*/
let path = UIBezierPath()
let numberOfLines = getNumberOfLines()
let spacing = getLineSpacing()
let lineHeight = getLineHeight()
var cornerRadius = CGFloat.zero
if case let .rectangle(cornerRadius: radius) = shape {
cornerRadius = radius / 2
}
for lineNumber in 0..<numberOfLines {
let y = (lineHeight + spacing) * CGFloat(lineNumber)
path.move(to: CGPoint(x: cornerRadius, y: y))
path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: y))
path.addQuadCurve(to: CGPoint(x: rect.width, y: y + cornerRadius),
controlPoint: CGPoint(x: rect.width, y: y))
path.addLine(to: CGPoint(x: rect.width, y: y + lineHeight - cornerRadius))
path.addQuadCurve(to: CGPoint(x: rect.width - cornerRadius, y: y + lineHeight),
controlPoint: CGPoint(x: rect.width, y: y + lineHeight))
path.addLine(to: CGPoint(x: cornerRadius, y: y + lineHeight))
path.addQuadCurve(to: CGPoint(x: .zero, y: y + lineHeight - cornerRadius),
controlPoint: CGPoint(x: .zero, y: y + lineHeight))
path.addLine(to: CGPoint(x: .zero, y: y + cornerRadius))
path.addQuadCurve(to: CGPoint(x: cornerRadius, y: y),
controlPoint: CGPoint(x: .zero, y: y))
}
return path.cgPath
}
open func configureLabelPath(label: UILabel) -> CGPath {
if case let .custom(path) = shape {
return path
}
isMultiline = label.isMultiline
font = label.font
labelNumberOfLines = label.numberOfLines
labelHeight = label.bounds.height
return drawPath(rect: label.bounds)
}
open func configureTextViewPath(textView: UITextView) -> CGPath {
if case let .custom(path) = shape {
return path
}
isMultiline = textView.isMultiline
font = textView.font
labelNumberOfLines = textView.textContainer.maximumNumberOfLines
labelHeight = textView.bounds.height
return drawPath(rect: textView.bounds)
}
// MARK: - Private methods
private func getLineHeight() -> CGFloat {
if let lineHeight = lineHeight?(font) {
return lineHeight
}
// By default height of the line is equal to 75% of font's size
return (font?.pointSize ?? 1) * 0.75
}
private func getLineSpacing() -> CGFloat {
if let lineSpacing = lineSpacing?(font) {
return lineSpacing
}
return font?.xHeight ?? .zero
}
private func getNumberOfLines() -> Int {
guard isMultiline else {
return 1
}
switch self.numberOfLines {
case let .constant(lines):
return lines
case .asLabelNumberOfLines:
return labelNumberOfLines
case .asLabelSize:
let lineHeight = getLineHeight()
return Int(labelHeight / lineHeight)
}
}
}

View File

@ -0,0 +1,94 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 TISwiftUtils
import UIKit
open class SkeletonsConfiguration {
public var viewConfiguration: BaseViewSkeletonsConfiguration
public var labelConfiguration: LabelSkeletonsConfiguration
public var imageViewConfiguration: BaseViewSkeletonsConfiguration
public var animation: ResultClosure<CAAnimationGroup>?
public var skeletonsBackgroundColor: CGColor
public var skeletonsMovingColor: CGColor
public var borderWidth: CGFloat
public weak var configurationDelegate: SkeletonsConfigurationDelegate?
public var isContainersHidden: Bool {
borderWidth == .zero
}
// MARK: - Init
public init(viewConfiguration: BaseViewSkeletonsConfiguration = .init(),
labelConfiguration: LabelSkeletonsConfiguration = .init(),
imageViewConfiguration: BaseViewSkeletonsConfiguration = .init(),
animation: ResultClosure<CAAnimationGroup>? = nil,
skeletonsBackgroundColor: UIColor = .lightGray.withAlphaComponent(0.7),
skeletonsMovingColor: UIColor = .lightGray.withAlphaComponent(0.2),
borderWidth: CGFloat = .zero,
configurationDelegate: SkeletonsConfigurationDelegate? = nil) {
self.viewConfiguration = viewConfiguration
self.labelConfiguration = labelConfiguration
self.imageViewConfiguration = imageViewConfiguration
self.animation = animation
self.skeletonsBackgroundColor = skeletonsBackgroundColor.cgColor
self.skeletonsMovingColor = skeletonsMovingColor.cgColor
self.borderWidth = borderWidth
self.configurationDelegate = configurationDelegate
}
// MARK: - Open methods
open func createSkeletonLayer(for baseView: UIView?) -> SkeletonLayer {
SkeletonLayer(config: self, baseView: baseView)
}
open func configureAppearance(layer: SkeletonLayer) {
layer.fillColor = skeletonsBackgroundColor
}
open func configureContainerAppearance(layer: SkeletonLayer) {
layer.fillColor = UIColor.clear.cgColor
if !isContainersHidden {
layer.borderColor = skeletonsBackgroundColor
layer.borderWidth = borderWidth
if case let .rectangle(cornerRadius: radius) = viewConfiguration.shape {
layer.cornerRadius = radius
}
}
}
open func configureAppearance(gradientLayer: CAGradientLayer) {
gradientLayer.colors = [
skeletonsBackgroundColor,
skeletonsMovingColor,
skeletonsBackgroundColor,
]
}
}

View File

@ -0,0 +1,29 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 QuartzCore
extension CALayer {
public var skeletonLayers: [SkeletonLayer] {
(sublayers ?? []).compactMap { $0 as? SkeletonLayer }
}
}

View File

@ -0,0 +1,84 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 UIKit
extension UIView {
public var skeletonableViews: [UIView] {
if let skeletonableView = self as? Skeletonable {
return skeletonableView.viewsToSkeletone
}
return subviews
}
var isSkeletonsContainer: Bool {
if let skeletonableView = self as? Skeletonable {
return !skeletonableView.viewsToSkeletone.isEmpty
}
return !subviews.isEmpty
}
var viewType: SkeletonLayer.ViewType {
if let labelView = self as? UILabel {
return .label(labelView)
} else if let textView = self as? UITextView {
return .textView(textView)
} else if let imageView = self as? UIImageView {
return .imageView(imageView)
} else if self.isSkeletonsContainer {
return .container(self)
} else {
return .generic(self)
}
}
}
extension UITextView {
var isMultiline: Bool {
guard let text = text, let font = font else {
return false
}
let labelTextSize = (text as NSString).size(withAttributes: [.font: font])
return labelTextSize.width > bounds.width
}
}
extension UILabel {
var isMultiline: Bool {
// Unwrapping font to mute worning while casting UIFont! to Any
guard let text = text, let font = font else {
return false
}
let labelTextSize = (text as NSString).size(withAttributes: [.font: font])
return labelTextSize.width > bounds.width
}
}

View File

@ -0,0 +1,27 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 class UIKit.UIView
public protocol Skeletonable {
var viewsToSkeletone: [UIView] { get }
}

View File

@ -0,0 +1,25 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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.
//
public protocol SkeletonsConfigurationDelegate: AnyObject {
func layerDidConfigured(forViewType type: SkeletonLayer.ViewType, layer: SkeletonLayer)
}

View File

@ -0,0 +1,154 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 UIKit
public protocol SkeletonsPresenter {
var baseView: UIView? { get }
var skeletonsConfiguration: SkeletonsConfiguration { get }
var isSkeletonsHidden: Bool { get }
var viewsToSkeletone: [UIView] { get }
func showSkeletons()
func hideSkeletons()
func startAnimation()
func stopAnimation()
}
// MARK: - SkeletonsPresenter + Default implemetation
extension SkeletonsPresenter {
public func showSkeletons() {
guard let baseView = baseView else {
return
}
baseView.isUserInteractionEnabled = false
viewsToSkeletone
.flatMap { view in
view.isHidden = true
return getSkeletonLayer(forView: view)
}
.map { layer in
layer.startAnimation()
return layer
}
.insert(onto: baseView)
}
public func hideSkeletons() {
guard let baseView = baseView else {
return
}
baseView.isUserInteractionEnabled = true
baseView.layer.sublayers?.forEach { layer in
if let layer = layer as? SkeletonLayer {
layer.remove(from: baseView)
}
}
baseView.skeletonableViews.forEach {
$0.isHidden = false
}
}
public func startAnimation() {
baseView?.layer.skeletonLayers.forEach { layer in
layer.startAnimation()
}
}
public func stopAnimation() {
baseView?.layer.skeletonLayers.forEach { layer in
layer.stopAnimation()
}
}
// MARK: - Private methods
private func getSkeletonLayer(forView view: UIView) -> [SkeletonLayer] {
let skeletonLayer = skeletonsConfiguration.createSkeletonLayer(for: baseView)
var subviewSkeletonLayers = [SkeletonLayer]()
if view.isSkeletonsContainer {
if skeletonsConfiguration.borderWidth != .zero {
skeletonLayer.bind(to: .container(view))
}
subviewSkeletonLayers = view.skeletonableViews
.map(getSkeletonLayer(forView:))
.flatMap { $0 }
} else {
skeletonLayer.bind(to: view.viewType)
}
return [skeletonLayer] + subviewSkeletonLayers
}
}
// MARK: - UIView + SkeletonsPresenter
extension SkeletonsPresenter where Self: UIView {
public var baseView: UIView? {
self
}
public var isSkeletonsHidden: Bool {
(layer.sublayers ?? []).first { $0 is SkeletonLayer } == nil
}
public var viewsToSkeletone: [UIView] {
skeletonableViews
}
}
// MARK: - UIViewController + SkeletonsPresenter
extension SkeletonsPresenter where Self: UIViewController {
public var baseView: UIView? {
view
}
public var isSkeletonsHidden: Bool {
(view.layer.sublayers ?? []).first { $0 is SkeletonLayer } == nil
}
public var viewsToSkeletone: [UIView] {
baseView?.skeletonableViews ?? view.skeletonableViews
}
}
// MARK: - Helper extension
extension Array where Element: CALayer {
public func insert(onto view: UIView, at index: UInt32 = .max) {
self.forEach { subLayer in
view.layer.insertSublayer(subLayer, at: index)
}
}
}

View File

@ -0,0 +1,159 @@
//
// Copyright (c) 2023 Touch Instinct
//
// 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 UIKit
open class SkeletonLayer: CAShapeLayer {
private enum Constants {
static var animationKeyPath: String {
"skeletonAnimation"
}
}
public enum ViewType {
case generic(UIView)
case container(UIView)
case label(UILabel)
case textView(UITextView)
case imageView(UIImageView)
public var view: UIView {
switch self {
case let .imageView(imageView):
return imageView
case let .container(containerView):
return containerView
case let .label(labelView):
return labelView
case let .textView(textView):
return textView
case let .generic(view):
return view
}
}
}
private var animationLayer = CAGradientLayer()
private var viewBoundsObservation: NSKeyValueObservation?
public var configuration: SkeletonsConfiguration
public weak var baseView: UIView?
// MARK: - Init
// For debug purposes in Lookin or other programs for view hierarchy inspections
public override init(layer: Any) {
self.configuration = .init()
super.init(layer: layer)
}
public init(config: SkeletonsConfiguration, baseView: UIView?) {
self.configuration = config
self.baseView = baseView
super.init()
}
public required init?(coder: NSCoder) {
self.configuration = .init()
super.init(coder: coder)
}
// MARK: - Open methods
open func bind(to viewType: ViewType) {
configureAppearance(viewType)
updateGeometry(viewType: viewType)
viewBoundsObservation = viewType.view.observe(\.frame, options: [.new]) { [weak self] view, _ in
view.isHidden = true
self?.updateGeometry(viewType: view.viewType)
}
configuration.configurationDelegate?.layerDidConfigured(forViewType: viewType, layer: self)
}
open func remove(from view: UIView) {
removeFromSuperlayer()
}
open func startAnimation() {
guard let animation = configuration.animation?() else {
return
}
animationLayer.add(animation, forKey: Constants.animationKeyPath)
mask = animationLayer
}
open func stopAnimation() {
animationLayer.removeAllAnimations()
mask = nil
}
// MARK: - Private methods
private func configureAppearance(_ type: ViewType) {
switch type {
case .container(_):
configuration.configureContainerAppearance(layer: self)
default:
configuration.configureAppearance(layer: self)
}
configuration.configureAppearance(gradientLayer: animationLayer)
}
private func updateGeometry(viewType: ViewType) {
frame = viewType.view.convert(viewType.view.bounds, to: baseView)
path = drawPath(viewType: viewType)
animationLayer.frame = bounds
}
private func drawPath(viewType: ViewType) -> CGPath {
var path: CGPath
switch viewType {
case let .textView(textView):
path = configuration.labelConfiguration.configureTextViewPath(textView: textView)
case let .label(label):
path = configuration.labelConfiguration.configureLabelPath(label: label)
case .imageView(_):
path = configuration.imageViewConfiguration.drawPath(rect: viewType.view.bounds)
default:
path = configuration.viewConfiguration.drawPath(rect: viewType.view.bounds)
}
return path
}
}

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIElements'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Bunch of useful protocols and views.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIKitCore'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Core UI elements: protocols, views and helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIWebView'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Universal web view API'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIYandexMapUtils'
s.version = '1.35.1'
s.version = '1.36.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Yandex Maps SDK.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

5
setup
View File

@ -3,5 +3,8 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$DIR"
# Set temporary environment variable
export SRCROOT=.
# Configure githooks folder path
git config core.hooksPath .githooks
git config core.hooksPath .githooks