diff --git a/CHANGELOG.md b/CHANGELOG.md index 615150b4..ed2000e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 1.37.0 + +- **Added**: API for converting view hierarchy to skeletons + ### 1.36.0 - **Removed**: `TILogger`module diff --git a/README.md b/README.md index 9e1cb04c..78bbc14b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This repository contains the following frameworks: ```sh cd TIModuleName -nef plaground --name TIModuleName --cocoapods --custom-podfile PlaygroundPodfile +nef playground --name TIModuleName --cocoapods --custom-podfile PlaygroundPodfile ``` See example of `PlaygroundPodfile` in `TIFoundationUtils` @@ -72,7 +72,7 @@ ${SRCROOT}/TIModuleName/TIModuleName.app" ```ruby sources = 'your_sources_expression' - if File.basename(Dir.getwd) == s.name # installing using :path => + if ENV["DEVELOPMENT_INSTALL"] # installing using :path => s.source_files = sources s.exclude_files = s.name + '.app' else diff --git a/TIAppleMapUtils/TIAppleMapUtils.podspec b/TIAppleMapUtils/TIAppleMapUtils.podspec index 0cf82b4a..a34512ab 100644 --- a/TIAppleMapUtils/TIAppleMapUtils.podspec +++ b/TIAppleMapUtils/TIAppleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAppleMapUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for map objects clustering and interacting using Apple MapKit.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIAuth/TIAuth.podspec b/TIAuth/TIAuth.podspec index 2a5733dd..415325cd 100644 --- a/TIAuth/TIAuth.podspec +++ b/TIAuth/TIAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAuth' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Login, registration, confirmation and other related actions' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIDeveloperUtils/TIDeveloperUtils.podspec b/TIDeveloperUtils/TIDeveloperUtils.podspec index 1386269f..bd91d3c8 100644 --- a/TIDeveloperUtils/TIDeveloperUtils.podspec +++ b/TIDeveloperUtils/TIDeveloperUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIDeveloperUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Universal web view API' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } @@ -13,8 +13,4 @@ Pod::Spec.new do |s| s.source_files = s.name + '/Sources/**/*' - s.dependency 'TIUIKitCore', s.version.to_s - s.dependency 'TIUIElements', s.version.to_s - s.dependency 'TISwiftUtils', s.version.to_s - end diff --git a/TIEcommerce/TIEcommerce.podspec b/TIEcommerce/TIEcommerce.podspec index 2c6b3cfc..21336cc2 100644 --- a/TIEcommerce/TIEcommerce.podspec +++ b/TIEcommerce/TIEcommerce.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIEcommerce' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Cart, products, promocodes, bonuses and other related actions' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIFoundationUtils/TIFoundationUtils.podspec b/TIFoundationUtils/TIFoundationUtils.podspec index c0017dcf..7f25292f 100644 --- a/TIFoundationUtils/TIFoundationUtils.podspec +++ b/TIFoundationUtils/TIFoundationUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIFoundationUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for Foundation framework classes.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIGoogleMapUtils/TIGoogleMapUtils.podspec b/TIGoogleMapUtils/TIGoogleMapUtils.podspec index 85aa6953..fc510d03 100644 --- a/TIGoogleMapUtils/TIGoogleMapUtils.podspec +++ b/TIGoogleMapUtils/TIGoogleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIGoogleMapUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for map objects clustering and interacting using Google Maps SDK.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIKeychainUtils/TIKeychainUtils.podspec b/TIKeychainUtils/TIKeychainUtils.podspec index 242a9151..80691c2c 100644 --- a/TIKeychainUtils/TIKeychainUtils.podspec +++ b/TIKeychainUtils/TIKeychainUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIKeychainUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for Keychain classes.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIMapUtils/TIMapUtils.podspec b/TIMapUtils/TIMapUtils.podspec index 0da3fd72..eba549c6 100644 --- a/TIMapUtils/TIMapUtils.podspec +++ b/TIMapUtils/TIMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMapUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for map objects clustering and interacting.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIMoyaNetworking/TIMoyaNetworking.podspec b/TIMoyaNetworking/TIMoyaNetworking.podspec index eef07140..c56d77dc 100644 --- a/TIMoyaNetworking/TIMoyaNetworking.podspec +++ b/TIMoyaNetworking/TIMoyaNetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMoyaNetworking' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Moya + Swagger network service.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TINetworking/TINetworking.podspec b/TINetworking/TINetworking.podspec index 9ac652b1..870958d9 100644 --- a/TINetworking/TINetworking.podspec +++ b/TINetworking/TINetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworking' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Swagger-frendly networking layer helpers.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TINetworkingCache/TINetworkingCache.podspec b/TINetworkingCache/TINetworkingCache.podspec index 8d99096c..10383fda 100644 --- a/TINetworkingCache/TINetworkingCache.podspec +++ b/TINetworkingCache/TINetworkingCache.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworkingCache' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Caching results of EndpointRequests.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIPagination/TIPagination.podspec b/TIPagination/TIPagination.podspec index a6842dff..bdda3f9c 100644 --- a/TIPagination/TIPagination.podspec +++ b/TIPagination/TIPagination.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIPagination' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Generic pagination component.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TISwiftUICore/TISwiftUICore.podspec b/TISwiftUICore/TISwiftUICore.podspec index 0c9e4c24..b0a984b4 100644 --- a/TISwiftUICore/TISwiftUICore.podspec +++ b/TISwiftUICore/TISwiftUICore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUICore' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Core UI elements: protocols, views and helpers.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TISwiftUtils/TISwiftUtils.podspec b/TISwiftUtils/TISwiftUtils.podspec index 75ceb559..d12eeb26 100644 --- a/TISwiftUtils/TISwiftUtils.podspec +++ b/TISwiftUtils/TISwiftUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Bunch of useful helpers for Swift development.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TITableKitUtils/TITableKitUtils.podspec b/TITableKitUtils/TITableKitUtils.podspec index 32ccee41..173f0d89 100644 --- a/TITableKitUtils/TITableKitUtils.podspec +++ b/TITableKitUtils/TITableKitUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TITableKitUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for TableKit classes.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIUIElements/PlaygroundPodfile b/TIUIElements/PlaygroundPodfile new file mode 100644 index 00000000..95938d81 --- /dev/null +++ b/TIUIElements/PlaygroundPodfile @@ -0,0 +1,10 @@ +ENV["DEVELOPMENT_INSTALL"] = "true" + +target 'TIUIElements' do + platform :ios, 11.0 + use_frameworks! + + pod 'TIUIElements', :path => '../../../../TIUIElements/TIUIElements.podspec' + pod 'TIUIKitCore', :path => '../../../../TIUIKitCore/TIUIKitCore.podspec' + pod 'TISwiftUtils', :path => '../../../../TISwiftUtils/TISwiftUtils.podspec' +end diff --git a/TIUIElements/Sources/Views/Skeletons/Animation/BaseSkeletonsAnimationConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Animation/BaseSkeletonsAnimationConfiguration.swift new file mode 100644 index 00000000..782617dc --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Animation/BaseSkeletonsAnimationConfiguration.swift @@ -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 + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Animation/DirectionalSkeletonsAnimationConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Animation/DirectionalSkeletonsAnimationConfiguration.swift new file mode 100644 index 00000000..5ecbc21e --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Animation/DirectionalSkeletonsAnimationConfiguration.swift @@ -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.5, + timingFunction: CAMediaTimingFunction? = nil) { + + self.direction = direction + + super.init(duration: duration, timingFunction: timingFunction) + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationBuilder.swift b/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationBuilder.swift new file mode 100644 index 00000000..45210858 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationBuilder.swift @@ -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 + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationDirection.swift b/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationDirection.swift new file mode 100644 index 00000000..de09b9a1 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Animation/SkeletonsAnimationDirection.swift @@ -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) + } + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift new file mode 100644 index 00000000..5ad77c0e --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift @@ -0,0 +1,58 @@ +// +// 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 padding: UIEdgeInsets + public var shape: Shape + + public init(padding: UIEdgeInsets = .edges(5), shape: Shape = .rectangle(cornerRadius: .zero)) { + self.shape = shape + self.padding = padding + } + + open func drawPath(rect: CGRect) -> CGPath { + switch shape { + case let .custom(path): + return path + + case let .rectangle(cornerRadius: cornerRadius): + let path = UIBezierPath(roundedRect: rect.reduceSize(byPadding: padding), cornerRadius: cornerRadius) + return path.cgPath + + case .circle: + return CGPath(ellipseIn: rect.reduceSize(byPadding: padding), transform: nil) + } + } + + open func applyPadding(viewFrame: CGRect) -> CGRect { + viewFrame.with(padding: padding) + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/ContainerViewSkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/ContainerViewSkeletonsConfiguration.swift new file mode 100644 index 00000000..98fc81ea --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/ContainerViewSkeletonsConfiguration.swift @@ -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 UIKit + +open class ContainerViewSkeletonsConfiguration: BaseViewSkeletonsConfiguration { + + public var borderWidth: CGFloat + + public init(borderWidth: CGFloat = .zero, + padding: UIEdgeInsets = .zero, + shape: Shape = .rectangle(cornerRadius: .zero)) { + + self.borderWidth = borderWidth + + super.init(padding: padding, shape: shape) + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/SkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/SkeletonsConfiguration.swift new file mode 100644 index 00000000..c46c8e62 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/SkeletonsConfiguration.swift @@ -0,0 +1,100 @@ +// +// 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 containerViewConfiguration: ContainerViewSkeletonsConfiguration + public var labelConfiguration: TextSkeletonsConfiguration + public var imageViewConfiguration: BaseViewSkeletonsConfiguration + public var animation: Closure? + + public var baseSkeletonBackgroundColor: CGColor? + public var skeletonsBackgroundColor: CGColor + public var skeletonsMovingColor: CGColor + + public weak var configurationDelegate: SkeletonsConfigurationDelegate? + + open var isContainersHidden: Bool { + containerViewConfiguration.borderWidth == .zero + } + + // MARK: - Init + + public init(viewConfiguration: BaseViewSkeletonsConfiguration = .init(), + containerViewConfiguration: ContainerViewSkeletonsConfiguration = .init(), + labelConfiguration: TextSkeletonsConfiguration = .init(), + imageViewConfiguration: BaseViewSkeletonsConfiguration = .init(shape: .circle), + animation: Closure? = nil, + baseSkeletonBackgroundColor: UIColor? = nil, + skeletonsBackgroundColor: UIColor = .lightGray.withAlphaComponent(0.7), + configurationDelegate: SkeletonsConfigurationDelegate? = nil) { + + self.viewConfiguration = viewConfiguration + self.containerViewConfiguration = containerViewConfiguration + self.labelConfiguration = labelConfiguration + self.imageViewConfiguration = imageViewConfiguration + self.animation = animation + self.baseSkeletonBackgroundColor = baseSkeletonBackgroundColor?.cgColor + self.skeletonsBackgroundColor = skeletonsBackgroundColor.cgColor + self.skeletonsMovingColor = skeletonsBackgroundColor.withAlphaComponent(0.2).cgColor + 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 configureBaseViewAppearance(layer: SkeletonLayer, view: UIView) { + layer.fillColor = baseSkeletonBackgroundColor ?? view.backgroundColor?.cgColor + } + + open func configureContainerAppearance(layer: SkeletonLayer) { + layer.fillColor = UIColor.clear.cgColor + + if !isContainersHidden { + layer.borderColor = skeletonsBackgroundColor + layer.borderWidth = containerViewConfiguration.borderWidth + + if case let .rectangle(cornerRadius: radius) = containerViewConfiguration.shape { + layer.cornerRadius = radius + } + } + } + + open func configureAppearance(gradientLayer: CAGradientLayer) { + gradientLayer.colors = [ + skeletonsBackgroundColor, + skeletonsMovingColor, + skeletonsBackgroundColor, + ] + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift new file mode 100644 index 00000000..ef68b06a --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift @@ -0,0 +1,160 @@ +// +// 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 TextSkeletonsConfiguration: BaseViewSkeletonsConfiguration { + + private enum Constants { + static var defaultNumberOfLines: Int { + 3 + } + } + + private var isMultiline = false + private var labelNumberOfLines: Int = .zero + private var labelHeight: CGFloat = .zero + private var font: UIFont? + + public var numberOfLines: Int? + public var lineHeight: Closure? + public var lineSpacing: Closure? + + public init(numberOfLines: Int? = nil, + lineHeight: Closure? = nil, + lineSpacing: Closure? = nil, + padding: UIEdgeInsets = .edges(5), + shape: Shape = .rectangle(cornerRadius: .zero)) { + + self.numberOfLines = numberOfLines + self.lineHeight = lineHeight + self.lineSpacing = lineSpacing + + super.init(padding: padding, 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.. 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.reduceSize(byPadding: padding)) + } + + 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.reduceSize(byPadding: padding)) + } + + // 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 + } + + if let numberOfLines = numberOfLines { + return numberOfLines + } + + return labelNumberOfLines == .zero + ? Constants.defaultNumberOfLines + : labelNumberOfLines + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Helpers/CALayer+Skeletons.swift b/TIUIElements/Sources/Views/Skeletons/Helpers/CALayer+Skeletons.swift new file mode 100644 index 00000000..82877f21 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Helpers/CALayer+Skeletons.swift @@ -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 } + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Helpers/CGRect+Padding.swift b/TIUIElements/Sources/Views/Skeletons/Helpers/CGRect+Padding.swift new file mode 100644 index 00000000..98702362 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Helpers/CGRect+Padding.swift @@ -0,0 +1,40 @@ +// +// 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 CGRect { + func with(padding: UIEdgeInsets) -> CGRect { + CGRect(x: minX + padding.left, + y: minY + padding.top, + width: width, + height: height) + } + + func reduceSize(byPadding padding: UIEdgeInsets) -> CGRect { + let reducedWidth = width - padding.left - padding.right + let reducedHeight = height - padding.top - padding.bottom + let reducedSize = CGSize(width: reducedWidth, height: reducedHeight) + + return CGRect(origin: origin, size: reducedSize) + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/Helpers/UIView+Skeletons.swift b/TIUIElements/Sources/Views/Skeletons/Helpers/UIView+Skeletons.swift new file mode 100644 index 00000000..fc8c98a5 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/Helpers/UIView+Skeletons.swift @@ -0,0 +1,121 @@ +// +// 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 TIUIKitCore +import UIKit + +// MARK: - UITextView + is multiline + +extension UITextView { + var isMultiline: Bool { + isMultiline(text: text, + attributedText: attributedText, + font: font, + textAlignment: textAlignment) + } +} + +// MARK: - UILabel + is multiline + +extension UILabel { + var isMultiline: Bool { + isMultiline(text: text, + attributedText: attributedText, + font: font, + textAlignment: textAlignment) + } +} + +// MARK: - UIView + Skeleton helpers + +extension UIView { + public var skeletonableViews: [UIView] { + if let skeletonableView = self as? Skeletonable { + return skeletonableView.viewsToSkeletons + } + + return subviews + } + + var isSkeletonsContainer: Bool { + if let skeletonableView = self as? Skeletonable { + return !skeletonableView.viewsToSkeletons.isEmpty + } + + return !subviews.isEmpty + } + + var viewType: SkeletonLayer.ViewType { + if let labelView = self as? UILabel { + return .label(labelView) + } + + if let textView = self as? UITextView { + return .textView(textView) + } + + if let imageView = self as? UIImageView { + return .imageView(imageView) + } + + if self.isSkeletonsContainer { + return .parentView(self) + } + + return .leafView(self) + } + + fileprivate func isMultiline(text: String?, + attributedText: NSAttributedString?, + font: UIFont?, + textAlignment: NSTextAlignment) -> Bool { + let finalText: String + let finalFont: UIFont + + if let attributedText = attributedText, let maxFont = attributedText.getMaxFont() { + finalText = attributedText.string + finalFont = maxFont + + } else if let text = text, let font = font { + finalText = text + finalFont = font + + } else { + return false + } + + let textAttributes = BaseTextAttributes(font: finalFont, color: .black, alignment: textAlignment, isMultiline: true) + let labelTextSize = textAttributes.size(of: finalText, with: .zero) + + return labelTextSize.width > bounds.width + } +} + +// MARK: - NSAttributedString helper extension + +extension NSAttributedString { + func getMaxFont() -> UIFont? { + (0.. Bool { + view.layer.skeletonLayers.isEmpty + } +} + +// MARK: - UIView + SkeletonsPresenter + +extension SkeletonsPresenter where Self: UIView { + public var skeletonsHolder: UIView { + self + } +} + +// MARK: - UIViewController + SkeletonsPresenter + +extension SkeletonsPresenter where Self: UIViewController { + public var skeletonsHolder: UIView { + view + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/SkeletonLayer.swift b/TIUIElements/Sources/Views/Skeletons/SkeletonLayer.swift new file mode 100644 index 00000000..758765ff --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/SkeletonLayer.swift @@ -0,0 +1,206 @@ +// +// 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 skeletonsHolderView(UIView) + case parentView(UIView) + case imageView(UIImageView) + case textView(UITextView) + case label(UILabel) + case leafView(UIView) + + public var view: UIView { + switch self { + case let .imageView(imageView): + return imageView + + case let .parentView(containerView): + return containerView + + case let .label(labelView): + return labelView + + case let .textView(textView): + return textView + + case let .leafView(view): + return view + + case let .skeletonsHolderView(view): + return view + } + } + } + + private var animationLayer = CAGradientLayer() + private var viewBoundsObservation: NSKeyValueObservation? + private var applicationStateObservation: NSObjectProtocol? + + public var configuration: SkeletonsConfiguration + public var isSkeletonsHolder: Bool = false + public weak var baseView: UIView? + + public var isAnimating: Bool { + animationLayer.animation(forKey: Constants.animationKeyPath) != nil + } + + // 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 + self?.updateGeometry(viewType: view.viewType) + } + + if let _ = configuration.animation?(self) { + applicationStateObservation = NotificationCenter.default + .addObserver(forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main) { [weak self] _ in + self?.stopAnimation() + self?.startAnimation() + } + } + + configuration.configurationDelegate?.layerDidConfigured(self, forViewType: viewType) + } + + open func remove(from view: UIView) { + stopAnimation() + removeFromSuperlayer() + viewBoundsObservation?.invalidate() + + if let observation = applicationStateObservation { + NotificationCenter.default.removeObserver(observation) + } + } + + open func startAnimation() { + guard !isAnimating, + !isSkeletonsHolder, + let animation = configuration.animation?(self) else { + return + } + + animationLayer.add(animation, forKey: Constants.animationKeyPath) + mask = animationLayer + } + + open func stopAnimation() { + animationLayer.removeAllAnimations() + mask?.removeFromSuperlayer() + } + + // MARK: - Private methods + + private func configureAppearance(_ type: ViewType) { + switch type { + case .parentView(_): + configuration.configureContainerAppearance(layer: self) + + case .skeletonsHolderView(_): + isSkeletonsHolder = true + configuration.configureBaseViewAppearance(layer: self, view: type.view) + + default: + configuration.configureAppearance(layer: self) + } + + configuration.configureAppearance(gradientLayer: animationLayer) + } + + private func updateGeometry(viewType: ViewType) { + let rect = viewType.view.convert(viewType.view.bounds, to: baseView) + + switch viewType { + case let .textView(textView): + path = configuration.labelConfiguration.configureTextViewPath(textView: textView) + + let viewFrame = CGRect(origin: rect.origin, size: path?.boundingBox.size ?? rect.size) + frame = configuration.labelConfiguration.applyPadding(viewFrame: viewFrame) + + case let .label(label): + path = configuration.labelConfiguration.configureLabelPath(label: label) + + let viewFrame = CGRect(origin: rect.origin, size: path?.boundingBox.size ?? rect.size) + frame = configuration.labelConfiguration.applyPadding(viewFrame: viewFrame) + + case .imageView(_): + path = configuration.imageViewConfiguration.drawPath(rect: viewType.view.bounds) + + let viewFrame = CGRect(origin: rect.origin, size: path?.boundingBox.size ?? rect.size) + frame = configuration.imageViewConfiguration.applyPadding(viewFrame: viewFrame) + + case .parentView(_): + path = configuration.containerViewConfiguration.drawPath(rect: viewType.view.bounds) + + let viewFrame = CGRect(origin: rect.origin, size: path?.boundingBox.size ?? rect.size) + frame = configuration.containerViewConfiguration.applyPadding(viewFrame: viewFrame) + + case .leafView(_): + path = configuration.viewConfiguration.drawPath(rect: viewType.view.bounds) + + let viewFrame = CGRect(origin: rect.origin, size: path?.boundingBox.size ?? rect.size) + frame = configuration.viewConfiguration.applyPadding(viewFrame: viewFrame) + + default: + path = UIBezierPath(roundedRect: rect, cornerRadius: 20).cgPath + frame = rect + } + + animationLayer.frame = bounds + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/UIView+PresentingSkeletons.swift b/TIUIElements/Sources/Views/Skeletons/UIView+PresentingSkeletons.swift new file mode 100644 index 00000000..47f49706 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/UIView+PresentingSkeletons.swift @@ -0,0 +1,113 @@ +// +// 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 + +extension UIView { + + // MARK: - Public methods + + /// Shows skeletons on the view + /// + /// - Parameters: + /// - viewsToSkeletons: views that will be converted to skeletons. If nil was passed subviews will be converted to skeletons + /// - config: configuration of the skeletons' layers + public func showSkeletons(viewsToSkeletons: [UIView]?, + _ config: SkeletonsConfiguration) { + + let viewsToSkeletons = viewsToSkeletons ?? skeletonableViews + isUserInteractionEnabled = false + + configureBaseLayer(withConfiguration: config) + + viewsToSkeletons + .flatMap { view in + getSkeletonLayer(forView: view, withConfiguration: config) + } + .map { layer in + layer.startAnimation() + + return layer + } + .insert(onto: self) + } + + public func hideSkeletons() { + isUserInteractionEnabled = true + + layer.skeletonLayers + .forEach { $0.remove(from: self) } + } + + public func startAnimation() { + layer.skeletonLayers + .forEach { $0.startAnimation() } + } + + public func stopAnimation() { + layer.skeletonLayers + .forEach { $0.stopAnimation() } + } + + // MARK: - Private methods + + private func getSkeletonLayer(forView view: UIView, + withConfiguration conf: SkeletonsConfiguration, + forceNoContainers: Bool = false) -> [SkeletonLayer] { + + let skeletonLayer = conf.createSkeletonLayer(for: self) + var subviewSkeletonLayers = [SkeletonLayer]() + + if view.isSkeletonsContainer { + if !conf.isContainersHidden, !forceNoContainers { + skeletonLayer.bind(to: .parentView(view)) + } + + subviewSkeletonLayers = view.skeletonableViews + .map { getSkeletonLayer(forView: $0, withConfiguration: conf, forceNoContainers: true) } + .flatMap { $0 } + + } else { + skeletonLayer.bind(to: view.viewType) + } + + return [skeletonLayer] + subviewSkeletonLayers + } + + private func configureBaseLayer(withConfiguration conf: SkeletonsConfiguration) { + let skeletonLayer = conf.createSkeletonLayer(for: self) + + skeletonLayer.bind(to: .skeletonsHolderView(self)) + layer.insertSublayer(skeletonLayer, at: .max) + } +} + +// 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) + } + } +} diff --git a/TIUIElements/Sources/Views/Skeletons/UIViewController+PresentingSkeletons.swift b/TIUIElements/Sources/Views/Skeletons/UIViewController+PresentingSkeletons.swift new file mode 100644 index 00000000..5da4d039 --- /dev/null +++ b/TIUIElements/Sources/Views/Skeletons/UIViewController+PresentingSkeletons.swift @@ -0,0 +1,49 @@ +// +// 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 UIViewController { + + /// Shows skeletons + /// + /// - Parameters: + /// - viewsToSkeletons: views that will be converted to skeletons. If nil was passed subviews of the view will be converted to skeletons + /// - config: configuration of the skeletons' layers + public func showSkeletons(viewsToSkeletons: [UIView]?, + _ config: SkeletonsConfiguration) { + + view.showSkeletons(viewsToSkeletons: viewsToSkeletons, config) + } + + public func hideSkeletons() { + view.hideSkeletons() + } + + public func startAnimation() { + view.startAnimation() + } + + public func stopAnimation() { + view.stopAnimation() + } +} diff --git a/TIUIElements/Sources/Views/StatefulButton/StatefulButton.swift b/TIUIElements/Sources/Views/StatefulButton/StatefulButton.swift index 2946332c..307d7df7 100644 --- a/TIUIElements/Sources/Views/StatefulButton/StatefulButton.swift +++ b/TIUIElements/Sources/Views/StatefulButton/StatefulButton.swift @@ -217,7 +217,6 @@ open class StatefulButton: UIButton { } else { updateAppearance(to: .disabled) } - } private func updateAppearance(to state: State) { diff --git a/TIUIElements/TIUIElements.app/.gitignore b/TIUIElements/TIUIElements.app/.gitignore new file mode 100644 index 00000000..b7fe13ce --- /dev/null +++ b/TIUIElements/TIUIElements.app/.gitignore @@ -0,0 +1,4 @@ +# gitignore nef files +**/build/ +**/nef/ +LICENSE \ No newline at end of file diff --git a/TIUIElements/TIUIElements.app/Contents/Info.plist b/TIUIElements/TIUIElements.app/Contents/Info.plist new file mode 100644 index 00000000..831ea97a --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + launcher + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + com.fortysevendeg.nef + CFBundleInfoDictionaryVersion + 6.0 + CFBundleSupportedPlatforms + + MacOSX + + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 10.14 + NSHumanReadableCopyright + Copyright © 2019 The nef Authors. All rights reserved. + + diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/.gitignore b/TIUIElements/TIUIElements.app/Contents/MacOS/.gitignore new file mode 100644 index 00000000..18bd1f3b --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/.gitignore @@ -0,0 +1,26 @@ +## gitignore nef files +**/build/ +**/nef/ +LICENSE + +## User data +**/xcuserdata/ +podfile.lock +**.DS_Store + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## CocoaPods +**Pods** + +## Carthage +**Carthage** + +## SPM +.build +.swiftpm +swiftpm diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/Podfile b/TIUIElements/TIUIElements.app/Contents/MacOS/Podfile new file mode 100644 index 00000000..ab33d1bc --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/Podfile @@ -0,0 +1,10 @@ +ENV["DEVELOPMENT_INSTALL"] = "true" + +target 'TIUIElements' do + platform :ios, 11.0 + use_frameworks! + + pod 'TIUIElements', :path => '../../../../TIUIElements/TIUIElements.podspec' + pod 'TISwiftUtils', :path => '../../../../TISwiftUtils/TISwiftUtils.podspec' + pod 'TIUIKitCore', :path => '../../../../TIUIKitCore/TIUIKitCore.podspec' +end diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Skeletons.xcplaygroundpage/Contents.swift b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Skeletons.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..b8d42bfe --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Skeletons.xcplaygroundpage/Contents.swift @@ -0,0 +1,315 @@ +/*: + # Skeletons API + + При импорте _TIUIElements_ вы можете использовать API для показа скелетонов. + + ## Принцип работы + + При использовании методов показа скелетонов: + 1. происходит скрытие всех subview в иерархии той view, на которой был вызван метод + 2. далее происходит проход по view, которые можно сконвертировать в скелетоны (список либо определяется пользователем, либо конвертация будет происходить автоматически), создается `CALayer` типа `SkeletonLayer`, представляющий конвертируемую view + 3. поверх view с которой начался показ, добавляются все созданные `SkeletonLayer` + + > Таким образом скелетоны не модифицируют размеры view и не изменяют ее положение + + ## Как начать пользоваться + + Базовая настройка для показа скелетонов не требуется. `UIView` и `UIViewController` уже имеют все необходимые методы для работы: + - `showSkeletons(viewsToSkeletons:_:)` : используется для показа скелетонов, если была передана конфигурация анимации, то она запустится автоматически. `viewsToSkeletons` - опциональный массив `UIView`, определяющий, какие вью будут конвертироваться в скелетоны. Если nil, то проход будет осуществляться по списку subview + - `hideSkeletons()` : используется для скрытия скелетонов + - `startAnimation()` : используется для старта анимации на скелетонах (если анимания не была сконфигурирована в методе `showSkeletons(viewsToSkeletons:_:)` то ничего не произойдет) + - `stopAnimation()` : используется для остановки анимации на скелетонах + */ +import TIUIKitCore +import TIUIElements +import UIKit + +class CanShowAndHideSkeletons: BaseInitializableViewController { + + private let imageView = UIImageView(image: UIImage(systemName: "apple.logo")) + private let label = UILabel() + private let button = UIButton(type: .custom) + + override func addViews() { + super.addViews() + + view.addSubviews(imageView, label, button) + } + + override func configureLayout() { + super.configureLayout() + + [imageView, label, button] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + imageView.heightAnchor.constraint(equalToConstant: 40), + imageView.widthAnchor.constraint(equalToConstant: 40), + imageView.centerYAnchor.constraint(equalTo: label.centerYAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + + label.topAnchor.constraint(equalTo: view.topAnchor), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor), + label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor), + label.heightAnchor.constraint(equalToConstant: 60), + + button.leadingAnchor.constraint(equalTo: view.leadingAnchor), + button.topAnchor.constraint(equalTo: label.bottomAnchor), + button.trailingAnchor.constraint(equalTo: view.trailingAnchor), + button.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func bindViews() { + super.bindViews() + + button.addTarget(self, action: #selector(toggleSkeletons), for: .touchUpInside) + } + + override func configureAppearance() { + super.configureAppearance() + + label.text = "Hello from SkeletonableViewController" + button.setTitle("show skeletons", for: .normal) + + let textAttributes = BaseTextAttributes(font: .systemFont(ofSize: 25), color: .black, alignment: .natural, isMultiline: false) + + view.configureUIView(appearance: UIView.DefaultAppearance(backgroundColor: .white)) + + label.configureUILabel(appearance: UILabel.DefaultAppearance.make { + $0.textAttributes = textAttributes + }) + + button.configureUIButton(appearance: UILabel.DefaultAppearance.make { + $0.textAttributes = textAttributes + }) + } + + @objc private func toggleSkeletons() { + // Т.к. передается nil, скелетониться будут все subview (в данном случае view.subview == [button, label, imageView]) + showSkeletons(viewsToSkeletons: nil, .init()) + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in + self?.hideSkeletons() + } + } +} + +/*: + ## Skeletonable + + Если необходимо изменить список конвертируемых в скелетоны view у какой-нибудь из отдельных view в иерархии, можно подписать его под протокол `Skeletonable` + */ +extension UITableViewCell: Skeletonable { + public var viewsToSkeletons: [UIView] { + contentView.subviews + } +} + +/*: + ## SkeletonsPresenter + + Чтобы не приходилось постоянно передавать в методы необходимые параметры для конфигурации можно соответствовать протоколу `SkeletonsPresenter`. Протокол дает возможность определять свойства для конфигурации скелетонов внутри view или viewController, вызывать метод `showSkeletons()` без передачи каких-либо параметров + + Перепишем _CanShowAndHideSkeletons_ под использование протокола + */ + +class CanShowAndHideWithSkeletonsPresenter: CanShowAndHideSkeletons, SkeletonsPresenter { + var skeletonsConfiguration: SkeletonsConfiguration { + SkeletonsConfiguration(skeletonsBackgroundColor: .gray) + } +} + +let canShowAndHideController = CanShowAndHideWithSkeletonsPresenter() + +//: Skeletons will be shown with custom configuration +canShowAndHideController.showSkeletons() + +/*: + ## Конфигурация внешнего вида + + Для конфигурации скелетонов существует класс `SkeletonsConfiguration` + */ +class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { + var skeletonsConfiguration: SkeletonsConfiguration { + .init(skeletonsBackgroundColor: .blue) + } +} + +/*: + Возможные опции для настройки: + + - анимация + - цвет + - форма + - отступы + + При этом все view делятся на: + - `UIView` с subviews (контейнеры) + - `UIView` без subviews + - `UILabel` + - `UITextView` + - `UIImageView` + + > Для контейнеров в качестве `borderColor` используется тот же цвет, что и для других скелетонов + + ### Анимация + + `SkeletonsConfiguration` для настройки анимации принимает тип `(SkeletonsLayer) -> CAAnimationGroup`. Это означает, что при необходимости вы можете создать любую необходимую анимацию. Анимация будет применена к каждому скелетону. + + Однако для удобства существует уже определенный класс `SkeletonsAnimationBuilder` со статическим методом `createDirectionalGradientAnimation(_:)` для создания анимаций в одну из сторон: + + ```swift + public enum SkeletonsAnimationDirection { + case leftToRight + case rightToLeft + case topToBottom + case bottomToTop + case topLeftToBottomRight + case topRightToBottomLeft + case bottomLeftToTopRight + case bottomRightToTopLeft + } + ``` + */ +let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in + let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .leftToRight, duration: 1.5) + return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) +}) + +let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in + let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .topToBottom, duration: 1.5) + return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) +}) + +/*: + ### Цвет + + За настройку цвета отвечает параметр `skeletonsBackgroundColor`: основной цвет скелетонов, им будт заливаться фон и выделяться _border_ + */ +let confWithRedBackgroundColor = SkeletonsConfiguration(skeletonsBackgroundColor: .red) + +//: Для скрытия view от пользователя скелетонами накладывается специальный CALayer, чей цвет заполнения равен backgroundColor view, с которой были запущены скелетоны. Однако этот цвет можно переопределить переопределить с помощью параметра `baseSkeletonBackgroundColor` +let confWithRedBaseBackgroundColor = SkeletonsConfiguration(baseSkeletonBackgroundColor: .red) + +/*: + ### Форма + + Форму можно настраивать отдельно для `UILabel`, `UITextView`, `UIImageView` и остальных вью. Например, картинки можно сделать круглыми, а лейблы прямоугольные с закругленными краями: + */ +var confWithShape: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(shape: .rectangle(cornerRadius: 10)) + let imageConf = BaseViewSkeletonsConfiguration(shape: .circle) + + return .init(labelConfiguration: labelConf, + imageViewConfiguration: imageConf) +} + +//: Для `UILabel` и `UITextView` есть возможность настроить высоту каждой строчки, расстояние между ними и их количество. +var confWithLabelSettings: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(numberOfLines: 3, + lineHeight: { font in + if let font = font { + return font.pointSize + } + return 10 + + }, lineSpacing: { font in + if let font = font { + return font.xHeight + } + return 5 + }) + return .init(labelConfiguration: labelConf) +} + +//: Для контейнеров можно настроить `borderWidth`. Стандартно он равняется 0, а значит контейнеры не будут показываться без дополнительной настройки ширины. +var skeletonsConfiguration: SkeletonsConfiguration { + let containerConf = ContainerViewSkeletonsConfiguration(borderWidth: 4, + shape: .rectangle(cornerRadius: 10)) + + return .init(containerViewConfiguration: containerConf) +} + +/*: + ### Отступы + + Отступы можно настроить отдельно для `UILabel`, `UITextView`, `UIImageView` и остальных вью. Например, для предыдущего примера можно добавить горизонтальный _padding_ для лейбла: + */ +var confWithPadding: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(padding: .horizontal(left: 15), shape: .rectangle(cornerRadius: 10)) + let imageConf = BaseViewSkeletonsConfiguration(shape: .circle) + + return .init(labelConfiguration: labelConf, + imageViewConfiguration: imageConf) +} + +/*: + ## Что если нужно больше? + + Если стандартной настройки не хватает, то в конфигуратор можно передать объект, соответствующий протоколу `SkeletonsConfigurationDelegate` через который можно настроить слой скелетона для каждой вью отдельно + + ```swift + public protocol SkeletonsConfigurationDelegate: AnyObject { + func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) + } + ``` + */ +class SkeletonsConfDelegate: SkeletonsConfigurationDelegate { + func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) { + if case .imageView(_) = type { + layer.frame = .init(x: layer.frame.minX - 20, + y: layer.frame.minY - 20, + width: layer.frame.width, + height: layer.frame.height) + } + } +} + +let delegate = SkeletonsConfDelegate() +let confWithDelegate = SkeletonsConfiguration(configurationDelegate: delegate) + +/*: + ### Особенности + + Т.к. размеры view на основе которой строятся скелетоны не модифицируются, может возникнуть ситуация, когда одни скелетоны перекрывают другие. Например, когда размер view меньше ее скелетонов. В таких случаях как раз может помочь установка позиции или размеров в методе делегата `SkeletonsConfigurationDelegate` + + Также в качестве способа обойти такую ситуацию можно передавать во view моковые данные для увеличения ее размеров, чтобы размеры были хотя бы примерно похожи на размер скелетонов, как в примере: + */ +extension DefaultTitleSubtitleView: SkeletonsPresenter { + public var skeletonsConfiguration: SkeletonsConfiguration { + .init(labelConfiguration: .init(numberOfLines: 3)) + } +} + +let titleSubtitleView = DefaultTitleSubtitleView() +titleSubtitleView.configure(appearance: .make { + $0.titleAppearance.update { + $0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true) + } + $0.subtitleAppearance.update { + $0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true) + } +}) +titleSubtitleView.configure(with: .init(title: "very very long mock string to make multiple lines", + subtitle: "very very long mock string to make multiple lines")) + +titleSubtitleView.showSkeletons() + +DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { + titleSubtitleView.configure(with: .init(title: "normal data from a request", + subtitle: "normal data from a request")) + titleSubtitleView.hideSkeletons() +} + +//: ## Тестовый сконфигурированный контроллер +import PlaygroundSupport + +canShowAndHideController.view.frame = .init(origin: .zero, size: .init(width: 250, height: 100)) + +canShowAndHideController.hideSkeletons() +confWithLeftToRightAnim.labelConfiguration = .init(numberOfLines: 2) + +PlaygroundPage.current.liveView = canShowAndHideController + +canShowAndHideController.showSkeletons(viewsToSkeletons: nil, confWithLeftToRightAnim) diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground new file mode 100644 index 00000000..3debe4b3 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.pbxproj b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.pbxproj new file mode 100644 index 00000000..52cfb0fd --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.pbxproj @@ -0,0 +1,395 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + B295B0E33533FFC3D833A6CB /* Pods_TIUIElements.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8E0E28C6F64363C77CAE4662 /* Pods_TIUIElements.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2E10916FC4CAF67D840FC3D2 /* Pods-TIUIElements.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Target Support Files/Pods-TIUIElements/Pods-TIUIElements.release.xcconfig"; sourceTree = ""; }; + 8BACBE8322576CAD00266845 /* TIUIElements.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TIUIElements.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BACBE8622576CAD00266845 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8E0E28C6F64363C77CAE4662 /* Pods_TIUIElements.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TIUIElements.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CB08C5B0C7051DCB015D3D9F /* Pods-TIUIElements.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TIUIElements.debug.xcconfig"; path = "Target Support Files/Pods-TIUIElements/Pods-TIUIElements.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8BACBE8022576CAD00266845 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B295B0E33533FFC3D833A6CB /* Pods_TIUIElements.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6FA8D567F06C39C360B32325 /* Pods */ = { + isa = PBXGroup; + children = ( + 2E10916FC4CAF67D840FC3D2 /* Pods-TIUIElements.release.xcconfig */, + CB08C5B0C7051DCB015D3D9F /* Pods-TIUIElements.debug.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 7672C2F734E0BBEC76B58962 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8E0E28C6F64363C77CAE4662 /* Pods_TIUIElements.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 8B39A26221D40F8700DE2643 = { + isa = PBXGroup; + children = ( + 8BACBE8422576CAD00266845 /* TIUIElements */, + 8B39A26C21D40F8700DE2643 /* Products */, + 6FA8D567F06C39C360B32325 /* Pods */, + 7672C2F734E0BBEC76B58962 /* Frameworks */, + ); + sourceTree = ""; + }; + 8B39A26C21D40F8700DE2643 /* Products */ = { + isa = PBXGroup; + children = ( + 8BACBE8322576CAD00266845 /* TIUIElements.framework */, + ); + name = Products; + sourceTree = ""; + }; + 8BACBE8422576CAD00266845 /* TIUIElements */ = { + isa = PBXGroup; + children = ( + 8BACBE8622576CAD00266845 /* Info.plist */, + ); + path = TIUIElements; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 8BACBE7E22576CAD00266845 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 8BACBE8222576CAD00266845 /* TIUIElements */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BACBE8A22576CAD00266845 /* Build configuration list for PBXNativeTarget "TIUIElements" */; + buildPhases = ( + 9D2C80D787CBCD9B1EA728B0 /* [CP] Check Pods Manifest.lock */, + 8BACBE7E22576CAD00266845 /* Headers */, + 8BACBE7F22576CAD00266845 /* Sources */, + 8BACBE8022576CAD00266845 /* Frameworks */, + 8BACBE8122576CAD00266845 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TIUIElements; + productName = TIUIElements2; + productReference = 8BACBE8322576CAD00266845 /* TIUIElements.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B39A26321D40F8700DE2643 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1010; + LastUpgradeCheck = 1200; + ORGANIZATIONNAME = "47 Degrees"; + TargetAttributes = { + 8BACBE8222576CAD00266845 = { + CreatedOnToolsVersion = 10.1; + }; + }; + }; + buildConfigurationList = 8B39A26621D40F8700DE2643 /* Build configuration list for PBXProject "TIUIElements" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8B39A26221D40F8700DE2643; + productRefGroup = 8B39A26C21D40F8700DE2643 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8BACBE8222576CAD00266845 /* TIUIElements */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8BACBE8122576CAD00266845 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9D2C80D787CBCD9B1EA728B0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TIUIElements-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8BACBE7F22576CAD00266845 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 8B39A27721D40F8800DE2643 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 8B39A27821D40F8800DE2643 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 8BACBE8822576CAD00266845 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CB08C5B0C7051DCB015D3D9F /* Pods-TIUIElements.debug.xcconfig */; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_TIUIElements_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/TIUIElements/Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.47deg.ios.TIUIElements; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 8BACBE8922576CAD00266845 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2E10916FC4CAF67D840FC3D2 /* Pods-TIUIElements.release.xcconfig */; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_TIUIElements_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/TIUIElements/Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.47deg.ios.TIUIElements; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8B39A26621D40F8700DE2643 /* Build configuration list for PBXProject "TIUIElements" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B39A27721D40F8800DE2643 /* Debug */, + 8B39A27821D40F8800DE2643 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8BACBE8A22576CAD00266845 /* Build configuration list for PBXNativeTarget "TIUIElements" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BACBE8822576CAD00266845 /* Debug */, + 8BACBE8922576CAD00266845 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8B39A26321D40F8700DE2643 /* Project object */; +} diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..4fdc4a67 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/xcshareddata/xcschemes/TIUIElements.xcscheme b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/xcshareddata/xcschemes/TIUIElements.xcscheme new file mode 100644 index 00000000..2ac02e93 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcodeproj/xcshareddata/xcschemes/TIUIElements.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcworkspace/contents.xcworkspacedata b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..313f1fa5 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements/Info.plist b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements/Info.plist new file mode 100644 index 00000000..98d14f60 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + NSHumanReadableCopyright + Copyright © 2019. The nef authors. + + diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/launcher b/TIUIElements/TIUIElements.app/Contents/MacOS/launcher new file mode 100755 index 00000000..dedc792f --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/launcher @@ -0,0 +1,6 @@ +#!/bin/bash + +workspace="TIUIElements.xcworkspace" +workspacePath=$(echo "$0" | rev | cut -f2- -d '/' | rev) + +open "`pwd`/$workspacePath/$workspace" diff --git a/TIUIElements/TIUIElements.app/Contents/Resources/AppIcon.icns b/TIUIElements/TIUIElements.app/Contents/Resources/AppIcon.icns new file mode 100644 index 00000000..32814f1c Binary files /dev/null and b/TIUIElements/TIUIElements.app/Contents/Resources/AppIcon.icns differ diff --git a/TIUIElements/TIUIElements.app/Contents/Resources/Assets.car b/TIUIElements/TIUIElements.app/Contents/Resources/Assets.car new file mode 100644 index 00000000..79d9ea89 Binary files /dev/null and b/TIUIElements/TIUIElements.app/Contents/Resources/Assets.car differ diff --git a/TIUIElements/TIUIElements.playground b/TIUIElements/TIUIElements.playground new file mode 120000 index 00000000..254203c3 --- /dev/null +++ b/TIUIElements/TIUIElements.playground @@ -0,0 +1 @@ +TIUIElements.app/Contents/MacOS/TIUIElements.playground \ No newline at end of file diff --git a/TIUIElements/TIUIElements.podspec b/TIUIElements/TIUIElements.podspec index 92f1292c..f5d6fc39 100644 --- a/TIUIElements/TIUIElements.podspec +++ b/TIUIElements/TIUIElements.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIElements' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Bunch of useful protocols and views.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIUIKitCore/Sources/TextAttributes/BaseTextAttributes/BaseTextAttributes.swift b/TIUIKitCore/Sources/TextAttributes/BaseTextAttributes/BaseTextAttributes.swift index 4a602848..bc589798 100644 --- a/TIUIKitCore/Sources/TextAttributes/BaseTextAttributes/BaseTextAttributes.swift +++ b/TIUIKitCore/Sources/TextAttributes/BaseTextAttributes/BaseTextAttributes.swift @@ -157,6 +157,9 @@ open class BaseTextAttributes { var configuration = UIButton.Configuration.plain() if let title = string { + button.setTitle(nil, for: .normal) + button.setAttributedTitle(nil, for: .normal) + configuration.attributedTitle = attributedString(for: title) button.configuration = configuration } diff --git a/TIUIKitCore/TIUIKitCore.podspec b/TIUIKitCore/TIUIKitCore.podspec index c9f10082..53294516 100644 --- a/TIUIKitCore/TIUIKitCore.podspec +++ b/TIUIKitCore/TIUIKitCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIKitCore' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Core UI elements: protocols, views and helpers.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIWebView/TIWebView.podspec b/TIWebView/TIWebView.podspec index 55d6fb1d..3133b457 100644 --- a/TIWebView/TIWebView.podspec +++ b/TIWebView/TIWebView.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIWebView' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Universal web view API' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIYandexMapUtils/TIYandexMapUtils.podspec b/TIYandexMapUtils/TIYandexMapUtils.podspec index 02e78c07..b680a945 100644 --- a/TIYandexMapUtils/TIYandexMapUtils.podspec +++ b/TIYandexMapUtils/TIYandexMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIYandexMapUtils' - s.version = '1.36.1' + s.version = '1.37.0' s.summary = 'Set of helpers for map objects clustering and interacting using Yandex Maps SDK.' s.homepage = 'https://gitlab.ti/touchinstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/docs/tiuielements/skeletons.md b/docs/tiuielements/skeletons.md new file mode 100644 index 00000000..13e66248 --- /dev/null +++ b/docs/tiuielements/skeletons.md @@ -0,0 +1,340 @@ + +# Skeletons API + + При импорте _TIUIElements_ вы можете использовать API для показа скелетонов. + +## Принцип работы + + При использовании методов показа скелетонов: + 1. происходит скрытие всех subview в иерархии той view, на которой был вызван метод + 2. далее происходит проход по view, которые можно сконвертировать в скелетоны (список либо определяется пользователем, либо конвертация будет происходить автоматически), создается `CALayer` типа `SkeletonLayer`, представляющий конвертируемую view + 3. поверх view с которой начался показ, добавляются все созданные `SkeletonLayer` + + > Таким образом скелетоны не модифицируют размеры view и не изменяют ее положение + +## Как начать пользоваться + + Базовая настройка для показа скелетонов не требуется. `UIView` и `UIViewController` уже имеют все необходимые методы для работы: + - `showSkeletons(viewsToSkeletons:_:)` : используется для показа скелетонов, если была передана конфигурация анимации, то она запустится автоматически. `viewsToSkeletons` - опциональный массив `UIView`, определяющий, какие вью будут конвертироваться в скелетоны. Если nil, то проход будет осуществляться по списку subview + - `hideSkeletons()` : используется для скрытия скелетонов + - `startAnimation()` : используется для старта анимации на скелетонах (если анимания не была сконфигурирована в методе `showSkeletons(viewsToSkeletons:_:)` то ничего не произойдет) + - `stopAnimation()` : используется для остановки анимации на скелетонах + +```swift +import TIUIKitCore +import TIUIElements +import UIKit + +class CanShowAndHideSkeletons: BaseInitializableViewController { + + private let imageView = UIImageView(image: UIImage(systemName: "apple.logo")) + private let label = UILabel() + private let button = UIButton(type: .custom) + + override func addViews() { + super.addViews() + + view.addSubviews(imageView, label, button) + } + + override func configureLayout() { + super.configureLayout() + + [imageView, label, button] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + imageView.heightAnchor.constraint(equalToConstant: 40), + imageView.widthAnchor.constraint(equalToConstant: 40), + imageView.centerYAnchor.constraint(equalTo: label.centerYAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + + label.topAnchor.constraint(equalTo: view.topAnchor), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor), + label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor), + label.heightAnchor.constraint(equalToConstant: 60), + + button.leadingAnchor.constraint(equalTo: view.leadingAnchor), + button.topAnchor.constraint(equalTo: label.bottomAnchor), + button.trailingAnchor.constraint(equalTo: view.trailingAnchor), + button.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func bindViews() { + super.bindViews() + + button.addTarget(self, action: #selector(toggleSkeletons), for: .touchUpInside) + } + + override func configureAppearance() { + super.configureAppearance() + + label.text = "Hello from SkeletonableViewController" + button.setTitle("show skeletons", for: .normal) + + let textAttributes = BaseTextAttributes(font: .systemFont(ofSize: 25), color: .black, alignment: .natural, isMultiline: false) + + view.configureUIView(appearance: UIView.DefaultAppearance(backgroundColor: .white)) + + label.configureUILabel(appearance: UILabel.DefaultAppearance.make { + $0.textAttributes = textAttributes + }) + + button.configureUIButton(appearance: UILabel.DefaultAppearance.make { + $0.textAttributes = textAttributes + }) + } + + @objc private func toggleSkeletons() { + // Т.к. передается nil, скелетониться будут все subview (в данном случае view.subview == [button, label, imageView]) + showSkeletons(viewsToSkeletons: nil, .init()) + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in + self?.hideSkeletons() + } + } +} +``` + +## Skeletonable + + Если необходимо изменить список конвертируемых в скелетоны view у какой-нибудь из отдельных view в иерархии, можно подписать его под протокол `Skeletonable` + +```swift +extension UITableViewCell: Skeletonable { + public var viewsToSkeletons: [UIView] { + contentView.subviews + } +} +``` + +## SkeletonsPresenter + + Чтобы не приходилось постоянно передавать в методы необходимые параметры для конфигурации можно соответствовать протоколу `SkeletonsPresenter`. Протокол дает возможность определять свойства для конфигурации скелетонов внутри view или viewController, вызывать метод `showSkeletons()` без передачи каких-либо параметров + + Перепишем _CanShowAndHideSkeletons_ под использование протокола + +```swift +class CanShowAndHideWithSkeletonsPresenter: CanShowAndHideSkeletons, SkeletonsPresenter { + var skeletonsConfiguration: SkeletonsConfiguration { + SkeletonsConfiguration(skeletonsBackgroundColor: .gray) + } +} + +let canShowAndHideController = CanShowAndHideWithSkeletonsPresenter() +``` + +Skeletons will be shown with custom configuration + +```swift +canShowAndHideController.showSkeletons() +``` + +## Конфигурация внешнего вида + + Для конфигурации скелетонов существует класс `SkeletonsConfiguration` + +```swift +class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { + var skeletonsConfiguration: SkeletonsConfiguration { + .init(skeletonsBackgroundColor: .blue) + } +} +``` + + Возможные опции для настройки: + + - анимация + - цвет + - форма + - отступы + + При этом все view делятся на: + - `UIView` с subviews (контейнеры) + - `UIView` без subviews + - `UILabel` + - `UITextView` + - `UIImageView` + + > Для контейнеров в качестве `borderColor` используется тот же цвет, что и для других скелетонов + +### Анимация + + `SkeletonsConfiguration` для настройки анимации принимает тип `(SkeletonsLayer) -> CAAnimationGroup`. Это означает, что при необходимости вы можете создать любую необходимую анимацию. Анимация будет применена к каждому скелетону. + + Однако для удобства существует уже определенный класс `SkeletonsAnimationBuilder` со статическим методом `createDirectionalGradientAnimation(_:)` для создания анимаций в одну из сторон: + + ```swift + public enum SkeletonsAnimationDirection { + case leftToRight + case rightToLeft + case topToBottom + case bottomToTop + case topLeftToBottomRight + case topRightToBottomLeft + case bottomLeftToTopRight + case bottomRightToTopLeft + } + ``` + +```swift +let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in + let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .leftToRight, duration: 1.5) + return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) +}) + +let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in + let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .topToBottom, duration: 1.5) + return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) +}) +``` + +### Цвет + + За настройку цвета отвечает параметр `skeletonsBackgroundColor`: основной цвет скелетонов, им будт заливаться фон и выделяться _border_ + +```swift +let confWithRedBackgroundColor = SkeletonsConfiguration(skeletonsBackgroundColor: .red) +``` + +Для скрытия view от пользователя скелетонами накладывается специальный CALayer, чей цвет заполнения равен backgroundColor view, с которой были запущены скелетоны. Однако этот цвет можно переопределить переопределить с помощью параметра `baseSkeletonBackgroundColor` + +```swift +let confWithRedBaseBackgroundColor = SkeletonsConfiguration(baseSkeletonBackgroundColor: .red) +``` + +### Форма + + Форму можно настраивать отдельно для `UILabel`, `UITextView`, `UIImageView` и остальных вью. Например, картинки можно сделать круглыми, а лейблы прямоугольные с закругленными краями: + +```swift +var confWithShape: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(shape: .rectangle(cornerRadius: 10)) + let imageConf = BaseViewSkeletonsConfiguration(shape: .circle) + + return .init(labelConfiguration: labelConf, + imageViewConfiguration: imageConf) +} +``` + +Для `UILabel` и `UITextView` есть возможность настроить высоту каждой строчки, расстояние между ними и их количество. + +```swift +var confWithLabelSettings: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(numberOfLines: 3, + lineHeight: { font in + if let font = font { + return font.pointSize + } + return 10 + + }, lineSpacing: { font in + if let font = font { + return font.xHeight + } + return 5 + }) + return .init(labelConfiguration: labelConf) +} +``` + +Для контейнеров можно настроить `borderWidth`. Стандартно он равняется 0, а значит контейнеры не будут показываться без дополнительной настройки ширины. + +```swift +var skeletonsConfiguration: SkeletonsConfiguration { + let containerConf = ContainerViewSkeletonsConfiguration(borderWidth: 4, + shape: .rectangle(cornerRadius: 10)) + + return .init(containerViewConfiguration: containerConf) +} +``` + +### Отступы + + Отступы можно настроить отдельно для `UILabel`, `UITextView`, `UIImageView` и остальных вью. Например, для предыдущего примера можно добавить горизонтальный _padding_ для лейбла: + +```swift +var confWithPadding: SkeletonsConfiguration { + let labelConf = TextSkeletonsConfiguration(padding: .horizontal(left: 15), shape: .rectangle(cornerRadius: 10)) + let imageConf = BaseViewSkeletonsConfiguration(shape: .circle) + + return .init(labelConfiguration: labelConf, + imageViewConfiguration: imageConf) +} +``` + +## Что если нужно больше? + + Если стандартной настройки не хватает, то в конфигуратор можно передать объект, соответствующий протоколу `SkeletonsConfigurationDelegate` через который можно настроить слой скелетона для каждой вью отдельно + + ```swift + public protocol SkeletonsConfigurationDelegate: AnyObject { + func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) + } + ``` + +```swift +class SkeletonsConfDelegate: SkeletonsConfigurationDelegate { + func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) { + if case .imageView(_) = type { + layer.frame = .init(x: layer.frame.minX - 20, + y: layer.frame.minY - 20, + width: layer.frame.width, + height: layer.frame.height) + } + } +} + +let delegate = SkeletonsConfDelegate() +let confWithDelegate = SkeletonsConfiguration(configurationDelegate: delegate) +``` + +### Особенности + + Т.к. размеры view на основе которой строятся скелетоны не модифицируются, может возникнуть ситуация, когда одни скелетоны перекрывают другие. Например, когда размер view меньше ее скелетонов. В таких случаях как раз может помочь установка позиции или размеров в методе делегата `SkeletonsConfigurationDelegate` + + Также в качестве способа обойти такую ситуацию можно передавать во view моковые данные для увеличения ее размеров, чтобы размеры были хотя бы примерно похожи на размер скелетонов, как в примере: + +```swift +extension DefaultTitleSubtitleView: SkeletonsPresenter { + public var skeletonsConfiguration: SkeletonsConfiguration { + .init(labelConfiguration: .init(numberOfLines: 3)) + } +} + +let titleSubtitleView = DefaultTitleSubtitleView() +titleSubtitleView.configure(appearance: .make { + $0.titleAppearance.update { + $0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true) + } + $0.subtitleAppearance.update { + $0.textAttributes = .init(font: .systemFont(ofSize: 15), color: .black, alignment: .natural, isMultiline: true) + } +}) +titleSubtitleView.configure(with: .init(title: "very very long mock string to make multiple lines", + subtitle: "very very long mock string to make multiple lines")) + +titleSubtitleView.showSkeletons() + +DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { + titleSubtitleView.configure(with: .init(title: "normal data from a request", + subtitle: "normal data from a request")) + titleSubtitleView.hideSkeletons() +} +``` + +## Тестовый сконфигурированный контроллер + +```swift +import PlaygroundSupport + +canShowAndHideController.view.frame = .init(origin: .zero, size: .init(width: 250, height: 100)) + +canShowAndHideController.hideSkeletons() +confWithLeftToRightAnim.labelConfiguration = .init(numberOfLines: 2) + +PlaygroundPage.current.liveView = canShowAndHideController + +canShowAndHideController.showSkeletons(viewsToSkeletons: nil, confWithLeftToRightAnim) +``` diff --git a/project-scripts/gen_docs_from_playgrounds.sh b/project-scripts/gen_docs_from_playgrounds.sh index 9e5c172a..1c5e3242 100755 --- a/project-scripts/gen_docs_from_playgrounds.sh +++ b/project-scripts/gen_docs_from_playgrounds.sh @@ -7,9 +7,10 @@ # SRCROOT - path to project folder. # -PLAYGROUNDS="${SRCROOT}/TIFoundationUtils/TIFoundationUtils.app" +PLAYGROUNDS="${SRCROOT}/TIFoundationUtils/TIFoundationUtils.app +${SRCROOT}/TIUIElements/TIUIElements.app" for playground_path in ${PLAYGROUNDS}; do nef compile --project ${playground_path} - nef markdown --project ${playground_path} --output ../docs -done \ No newline at end of file + nef markdown --project ${playground_path} --output ${SRCROOT}/docs +done diff --git a/setup b/setup index 97d7cd9d..4bdaeffc 100755 --- a/setup +++ b/setup @@ -3,5 +3,8 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$DIR" +# Set temporary environment variable +export SRCROOT=$DIR + # Configure githooks folder path -git config core.hooksPath .githooks \ No newline at end of file +git config core.hooksPath .githooks