diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb962b5..0b776232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### 1.40.0 + +- **Added**: `PlaceholderFactory` for creating `DefaultPlaceholderView` views +- **Added**: `DefaultPlaceholderImageView` + ### 1.39.0 - **Added**: UIButton Appearance model diff --git a/TIAppleMapUtils/TIAppleMapUtils.podspec b/TIAppleMapUtils/TIAppleMapUtils.podspec index 837ddc85..04e5a334 100644 --- a/TIAppleMapUtils/TIAppleMapUtils.podspec +++ b/TIAppleMapUtils/TIAppleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAppleMapUtils' - s.version = '1.39.0' + s.version = '1.40.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 884ed177..4a25f30e 100644 --- a/TIAuth/TIAuth.podspec +++ b/TIAuth/TIAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAuth' - s.version = '1.39.0' + s.version = '1.40.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 8c4e076b..94c0af39 100644 --- a/TIDeveloperUtils/TIDeveloperUtils.podspec +++ b/TIDeveloperUtils/TIDeveloperUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIDeveloperUtils' - s.version = '1.39.0' + s.version = '1.40.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/TIEcommerce/TIEcommerce.podspec b/TIEcommerce/TIEcommerce.podspec index f6696364..ca618545 100644 --- a/TIEcommerce/TIEcommerce.podspec +++ b/TIEcommerce/TIEcommerce.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIEcommerce' - s.version = '1.39.0' + s.version = '1.40.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 3dd87f16..fe121109 100644 --- a/TIFoundationUtils/TIFoundationUtils.podspec +++ b/TIFoundationUtils/TIFoundationUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIFoundationUtils' - s.version = '1.39.0' + s.version = '1.40.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 f5e13de7..e03444b9 100644 --- a/TIGoogleMapUtils/TIGoogleMapUtils.podspec +++ b/TIGoogleMapUtils/TIGoogleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIGoogleMapUtils' - s.version = '1.39.0' + s.version = '1.40.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 25c4fa16..f211ca7d 100644 --- a/TIKeychainUtils/TIKeychainUtils.podspec +++ b/TIKeychainUtils/TIKeychainUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIKeychainUtils' - s.version = '1.39.0' + s.version = '1.40.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 94019482..6a78ddd0 100644 --- a/TIMapUtils/TIMapUtils.podspec +++ b/TIMapUtils/TIMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMapUtils' - s.version = '1.39.0' + s.version = '1.40.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 c8d020a5..9157d4fb 100644 --- a/TIMoyaNetworking/TIMoyaNetworking.podspec +++ b/TIMoyaNetworking/TIMoyaNetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMoyaNetworking' - s.version = '1.39.0' + s.version = '1.40.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 e26aa3b6..77132491 100644 --- a/TINetworking/TINetworking.podspec +++ b/TINetworking/TINetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworking' - s.version = '1.39.0' + s.version = '1.40.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 3292f422..5ea18467 100644 --- a/TINetworkingCache/TINetworkingCache.podspec +++ b/TINetworkingCache/TINetworkingCache.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworkingCache' - s.version = '1.39.0' + s.version = '1.40.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 c2327dde..5bfaba1b 100644 --- a/TIPagination/TIPagination.podspec +++ b/TIPagination/TIPagination.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIPagination' - s.version = '1.39.0' + s.version = '1.40.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 cad3bd44..6c399c22 100644 --- a/TISwiftUICore/TISwiftUICore.podspec +++ b/TISwiftUICore/TISwiftUICore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUICore' - s.version = '1.39.0' + s.version = '1.40.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 f610dc08..b4acb6c6 100644 --- a/TISwiftUtils/TISwiftUtils.podspec +++ b/TISwiftUtils/TISwiftUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUtils' - s.version = '1.39.0' + s.version = '1.40.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 e7cdf5c4..13ad427d 100644 --- a/TITableKitUtils/TITableKitUtils.podspec +++ b/TITableKitUtils/TITableKitUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TITableKitUtils' - s.version = '1.39.0' + s.version = '1.40.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/Sources/Appearance/UIView+Appearance.swift b/TIUIElements/Sources/Appearance/UIView+Appearance.swift index b2ac86d0..eafab957 100644 --- a/TIUIElements/Sources/Appearance/UIView+Appearance.swift +++ b/TIUIElements/Sources/Appearance/UIView+Appearance.swift @@ -138,6 +138,10 @@ extension UIView { } } + open class BaseWrappedAppearance: BaseAppearance { + + } + public final class DefaultWrappedViewHolderAppearance: BaseWrappedViewHolderAppearance, WrappedViewHolderAppearance { @@ -146,7 +150,7 @@ extension UIView { } } - public final class DefaultWrappedAppearance: BaseAppearance, WrappedViewAppearance { + public final class DefaultWrappedAppearance: BaseWrappedAppearance, WrappedViewAppearance { public static var defaultAppearance: Self { Self() } diff --git a/TIUIElements/Sources/Helpers/SubviewConstraints.swift b/TIUIElements/Sources/Helpers/SubviewConstraints.swift new file mode 100644 index 00000000..b02f1e4f --- /dev/null +++ b/TIUIElements/Sources/Helpers/SubviewConstraints.swift @@ -0,0 +1,52 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import class UIKit.NSLayoutConstraint + +public struct SubviewConstraints { + public var centerXConstraint: NSLayoutConstraint? + public var centerYConstraint: NSLayoutConstraint? + public var leadingConstraint: NSLayoutConstraint? + public var topConstraint: NSLayoutConstraint? + public var trailingConstraint: NSLayoutConstraint? + public var bottomConstraint: NSLayoutConstraint? + public var widthConstraint: NSLayoutConstraint? + public var heightConstraint: NSLayoutConstraint? + + public var constraints: [NSLayoutConstraint] { + [ + centerXConstraint, + leadingConstraint, + topConstraint, + trailingConstraint, + bottomConstraint, + widthConstraint, + heightConstraint + ] + .compactMap { $0 } + } + + public var sizeConstraints: [NSLayoutConstraint] { + [widthConstraint, heightConstraint] + .compactMap { $0 } + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Localization/DefaultPlaceholderLocalizationProvider.swift b/TIUIElements/Sources/Views/Placeholder/Localization/DefaultPlaceholderLocalizationProvider.swift new file mode 100644 index 00000000..ade64dec --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Localization/DefaultPlaceholderLocalizationProvider.swift @@ -0,0 +1,72 @@ +// +// 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 + +open class DefaultPlaceholderLocalizationProvider: PlaceholderLocalizationProvider { + + public enum Defaults { + public static var errorKey: String { + "common_placeholder_title_error" + } + + public static var loadingDataErrorKey: String { + "common_placeholder_title_loading_data" + } + + public static var emptyStateKey: String { + "common_placeholder_title_empty_state" + } + + public static var repeatKey: String { + "common_global_repeat" + } + } + + public var bundle: Bundle + public var tableName: String? + + // MARK: - PlaceholderLocalizationProvider + + open var errorTitle: String { + bundle.localizedString(forKey: Defaults.errorKey, value: "An error has occured.", table: tableName) + } + + open var loadingDataErrorTitle: String { + bundle.localizedString(forKey: Defaults.loadingDataErrorKey, value: "Failed to load data.", table: tableName) + } + + open var emptyStateTitle: String { + bundle.localizedString(forKey: Defaults.emptyStateKey, value: "The list is empty.", table: tableName) + } + + open var repeatButtonTitle: String { + bundle.localizedString(forKey: Defaults.repeatKey, value: "Repeat", table: tableName) + } + + // MARK: - Init + + public init(bundle: Bundle = .main, tableName: String? = nil) { + self.bundle = bundle + self.tableName = tableName + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Localization/PlaceholderLocalizationProvider.swift b/TIUIElements/Sources/Views/Placeholder/Localization/PlaceholderLocalizationProvider.swift new file mode 100644 index 00000000..b25faba5 --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Localization/PlaceholderLocalizationProvider.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +public protocol PlaceholderLocalizationProvider { + var errorTitle: String { get } + var loadingDataErrorTitle: String { get } + var emptyStateTitle: String { get } + var repeatButtonTitle: String { get } +} diff --git a/TIUIElements/Sources/Views/Placeholder/PlaceholderFactory.swift b/TIUIElements/Sources/Views/Placeholder/PlaceholderFactory.swift new file mode 100644 index 00000000..3027b9ab --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/PlaceholderFactory.swift @@ -0,0 +1,153 @@ +// +// 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 + +open class PlaceholderFactory { + public enum Defaults { + public static var errorStyleImageName: String { + "placeholder_error_icon" + } + + public static var loadingDataErrorImageName: String { + "placeholder_loading_data_icon" + } + + public static var emptyStateImageName: String { + "placeholder_empty_state_icon" + } + } + + public var localizationProvider: PlaceholderLocalizationProvider + + public init(localizationProvider: PlaceholderLocalizationProvider = DefaultPlaceholderLocalizationProvider()) { + self.localizationProvider = localizationProvider + } + + // MARK: - Default styles creation + + open func errorStyle() -> DefaultPlaceholderStyle { + .defaultStyle { style in + style.image = UIImage(named: Defaults.errorStyleImageName) + style.titleSubtitle = DefaultTitleSubtitleViewModel(title: localizationProvider.errorTitle) + } + .updateAppearance { placeholder in + placeholder.backgroundColor = .white + + placeholder.imageViewAppearance.layout { + $0.size = .fixedHeight(250) + $0.centerOffset = .centerVertical(-125) + } + + placeholder.textViewAppearance = Self.defaultTitlesAppearance { titleSubtitle in + titleSubtitle.layout { + $0.spacing = 8 + $0.insets = .vertical(23) + } + } + + placeholder.controlsViewAppearance.layout { + $0.insets = .horizontal(66) + $0.size = .fixedHeight(52) + } + } + .withButton { buttonStyle in + buttonStyle.titles = [.normal: localizationProvider.repeatButtonTitle] + buttonStyle.appearance = [.normal: Self.defaultButtonAppearance] + } + } + + open func loadingDataErrorStyle() -> DefaultPlaceholderStyle { + errorStyle().update { placeholder in + placeholder.image = UIImage(named: Defaults.loadingDataErrorImageName) + placeholder.titleSubtitle = DefaultTitleSubtitleViewModel(title: localizationProvider.loadingDataErrorTitle) + } + } + + open func emptyStateStyle() -> DefaultPlaceholderStyle { + errorStyle().update { placeholder in + placeholder.image = UIImage(named: Defaults.emptyStateImageName) + placeholder.titleSubtitle = DefaultTitleSubtitleViewModel(title: localizationProvider.emptyStateTitle) + placeholder.buttonsStyles = [] + } + } + + + // MARK: - Placeholder creation + + open func createEmptyStatePlaceholder() -> DefaultPlaceholderView { + createImageStylePlaceholder(emptyStateStyle()) + } + + open func createErrorPlaceholder() -> DefaultPlaceholderView { + createImageStylePlaceholder(errorStyle()) + } + + open func createLoadingDataErrorPlaceholder() -> DefaultPlaceholderView { + createImageStylePlaceholder(loadingDataErrorStyle()) + } + + // MARK: - Helper methods + + open func createImageStylePlaceholder(_ style: DefaultPlaceholderStyle = .defaultStyle) -> DefaultPlaceholderView { + let view = DefaultPlaceholderView() + view.apply(style: style) + + return view + } +} + +// MARK: - Private configurations + +private extension PlaceholderFactory { + static var defaultButtonAppearance: UIButton.DefaultAppearance { + .make { + $0.border.cornerRadius = 25 + $0.border.roundedCorners = .allCorners + $0.backgroundColor = UIColor(red: 0.892, green: 0.906, blue: 0.92, alpha: 0.5) + $0.textAttributes = .init(font: .systemFont(ofSize: 20, weight: .bold), + color: .black, + alignment: .natural, + isMultiline: false) + } + } + + static var defaultTitlesAppearance: DefaultTitleSubtitleView.Appearance { + .make { titleSubtitle in + titleSubtitle.titleAppearance { title in + title.textAttributes = Self.defaultTextAttributes + } + + titleSubtitle.subtitleAppearance { subtitle in + subtitle.textAttributes = Self.defaultTextAttributes + } + } + } + + static var defaultTextAttributes: BaseTextAttributes { + .init(font: .systemFont(ofSize: 20, weight: .light), + color: .black, + alignment: .center, + isMultiline: false) + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Styles/BasePlaceholderStyle.swift b/TIUIElements/Sources/Views/Placeholder/Styles/BasePlaceholderStyle.swift new file mode 100644 index 00000000..a4a052b7 --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Styles/BasePlaceholderStyle.swift @@ -0,0 +1,43 @@ +// +// 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 + +open class BasePlaceholderStyle { + + public var titleSubtitle: DefaultTitleSubtitleViewModel + public var controlsViewAxis: NSLayoutConstraint.Axis + public var appearance: Appearance + public var buttonsStyles: [PlaceholderButtonStyle] + + public init(titleSubtitle: DefaultTitleSubtitleViewModel = .init(), + appearance: Appearance = .defaultAppearance, + controlsViewAxis: NSLayoutConstraint.Axis = .vertical, + buttonsStyles: [PlaceholderButtonStyle] = []) { + + self.titleSubtitle = titleSubtitle + self.appearance = appearance + self.controlsViewAxis = controlsViewAxis + self.buttonsStyles = buttonsStyles + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Styles/DefaultPlaceholderStyle.swift b/TIUIElements/Sources/Views/Placeholder/Styles/DefaultPlaceholderStyle.swift new file mode 100644 index 00000000..6e1f9cb0 --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Styles/DefaultPlaceholderStyle.swift @@ -0,0 +1,48 @@ +// +// 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 + +public final class DefaultPlaceholderStyle: BasePlaceholderStyle, + PlaceholderStyle { + + static public var defaultStyle: Self { + .init() + } + + public var image: UIImage? + + public init(image: UIImage? = nil, + titleSubtitle: DefaultTitleSubtitleViewModel = .init(), + appearance: DefaultPlaceholderView.Appearance = .defaultAppearance, + controlsViewAxis: NSLayoutConstraint.Axis = .vertical, + buttonsStyles: [PlaceholderButtonStyle] = []) { + + self.image = image + + super.init(titleSubtitle: titleSubtitle, + appearance: appearance, + controlsViewAxis: controlsViewAxis, + buttonsStyles: buttonsStyles) + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift b/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift new file mode 100644 index 00000000..ea9e1fdb --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift @@ -0,0 +1,48 @@ + +// +// 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 + +open class PlaceholderButtonStyle { + + public var titles: UIControl.StateTitles + public var images: UIControl.StateImages + public var appearance: StatefulButton.StateAppearance + public var action: UIButton.Action? + + public init(titles: UIControl.StateTitles = [:], + images: UIControl.StateImages = [:], + appearance: StatefulButton.StateAppearance = [:], + action: UIButton.Action? = nil) { + + self.titles = titles + self.images = images + self.appearance = appearance + self.action = action + } +} + +public extension UIButton { + typealias Action = (target: Any?, action: Selector, event: UIControl.Event) +} diff --git a/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderStyle.swift b/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderStyle.swift new file mode 100644 index 00000000..98278480 --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderStyle.swift @@ -0,0 +1,95 @@ +// +// 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 TIUIKitCore +import UIKit + +public protocol PlaceholderStyle: AnyObject { + associatedtype PlaceholderAppearance: ViewAppearance + + static var defaultStyle: Self { get } + + var titleSubtitle: DefaultTitleSubtitleViewModel { get set } + var controlsViewAxis: NSLayoutConstraint.Axis { get set } + var appearance: PlaceholderAppearance { get set } + var buttonsStyles: [PlaceholderButtonStyle] { get set } +} + +// MARK: - Builder methods + +public extension PlaceholderStyle { + static func callAsFunction(_ builder: ParameterClosure) -> Self { + make(builder) + } + + static func make(_ builder: ParameterClosure) -> Self { + let style = Self.defaultStyle + builder(style) + + return style + } + + func callAsFunction(_ builder: ParameterClosure) -> Self { + update(builder) + } + + func update(_ builder: ParameterClosure) -> Self { + builder(self) + return self + } + + func updateAppearance(_ builder: ParameterClosure) -> Self { + builder(appearance) + return self + } + + func withButton(_ builder: ParameterClosure) -> Self { + let buttonStyle = PlaceholderButtonStyle() + + builder(buttonStyle) + buttonsStyles.append(buttonStyle) + + return self + } + + func withButtons(_ amount: Int, + axis: NSLayoutConstraint.Axis, + _ builder: (Int, PlaceholderButtonStyle) -> Void) -> Self { + + controlsViewAxis = axis + + for index in 0..: UIImageView, + InitializableViewProtocol { + + public let placeholderView = Placeholder() + + public var placeholderConstraints: SubviewConstraints? + + open override var image: UIImage? { + get { + super.image + } + set { + placeholderView.isHidden = newValue != nil + + super.image = newValue + } + } + + // MARK: - Init + + override public init(image: UIImage?) { + super.init(image: image) + + initializeView() + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + initializeView() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + initializeView() + } + + // MARK: - InitializableViewProtocol + + open func addViews() { + addSubview(placeholderView) + } + + open func configureLayout() { + placeholderView.translatesAutoresizingMaskIntoConstraints = false + + let placeholderConstraints = SubviewConstraints( + centerXConstraint: placeholderView.centerXAnchor.constraint(equalTo: centerXAnchor), + centerYConstraint: placeholderView.centerYAnchor.constraint(equalTo: centerYAnchor), + leadingConstraint: placeholderView.leadingAnchor.constraint(equalTo: leadingAnchor), + topConstraint: placeholderView.topAnchor.constraint(equalTo: topAnchor), + trailingConstraint: placeholderView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint: placeholderView.bottomAnchor.constraint(equalTo: bottomAnchor), + widthConstraint: placeholderView.widthAnchor.constraint(equalToConstant: .zero), + heightConstraint: placeholderView.heightAnchor.constraint(equalToConstant: .zero)) + + NSLayoutConstraint.activate(placeholderConstraints.constraints) + NSLayoutConstraint.deactivate(placeholderConstraints.sizeConstraints) + + self.placeholderConstraints = placeholderConstraints + } + + open func bindViews() { + // override in subviews + } + + open func configureAppearance() { + // override in subviews + } + + open func localize() { + // override in subviews + } + + // MARK: - Open methods + + open func configureBasePlaceholder(appearance: BaseWrappedViewHolderAppearance) { + configureUIView(appearance: appearance) + + placeholderView.configureUIView(appearance: appearance.subviewAppearance) + configurePlaceholderLayout(layout: appearance.subviewAppearance.layout) + } + + // MARK: - Private methods + + private func configurePlaceholderLayout(layout: some WrappedViewLayout) { + layout.setupSize(widthConstraint: placeholderConstraints?.widthConstraint, + heightConstraint: placeholderConstraints?.heightConstraint) + + layout.setupCenterYOffset(centerYConstraint: placeholderConstraints?.centerYConstraint, + topConstraint: placeholderConstraints?.topConstraint, + bottomConstraint: placeholderConstraints?.bottomConstraint) + + layout.setupCenterXOffset(centerXConstraint: placeholderConstraints?.centerXConstraint, + leadingConstraint: placeholderConstraints?.leadingConstraint, + trailingConstraint: placeholderConstraints?.trailingConstraint) + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Views/BasePlaceholderView.swift b/TIUIElements/Sources/Views/Placeholder/Views/BasePlaceholderView.swift new file mode 100644 index 00000000..55663664 --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Views/BasePlaceholderView.swift @@ -0,0 +1,272 @@ +// +// 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 TIUIKitCore +import UIKit + +open class BasePlaceholderView: BaseInitializableView { + + public let imageView = ImageView() + public let textView = DefaultTitleSubtitleView() + public let controlsStackView = UIStackView() + + public var imageViewConstraints: SubviewConstraints? + public var textViewConstraints: SubviewConstraints? + public var controlsViewConstraints: SubviewConstraints? + + public var keyboardDidShownObserver: NSObjectProtocol? + public var keyboardDidHiddenObserver: NSObjectProtocol? + + open var isImageViewHidden: Bool { + imageView.isHidden + } + + open var isControlsViewHidden: Bool { + controlsStackView.isHidden || controlsStackView.arrangedSubviews.isEmpty + } + + // MARK: - Deinit + + deinit { + if let keyboardDidShownObserver = keyboardDidShownObserver { + NotificationCenter.default.removeObserver(keyboardDidShownObserver) + } + + if let keyboardDidHiddenObserver = keyboardDidHiddenObserver { + NotificationCenter.default.removeObserver(keyboardDidHiddenObserver) + } + } + + // MARK: - BaseInitializableView + + open override func addViews() { + super.addViews() + + addSubviews(imageView, textView, controlsStackView) + } + + open override func configureLayout() { + super.configureLayout() + + [imageView, textView, controlsStackView] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + imageViewConstraints = .init( + centerXConstraint: imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + centerYConstraint: imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + leadingConstraint: imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + topConstraint: imageView.topAnchor.constraint(equalTo: topAnchor), + trailingConstraint: imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint: imageView.bottomAnchor.constraint(equalTo: textView.topAnchor), + widthConstraint: imageView.widthAnchor.constraint(equalToConstant: .zero), + heightConstraint: imageView.heightAnchor.constraint(equalToConstant: .zero)) + + textViewConstraints = .init( + centerXConstraint: textView.centerXAnchor.constraint(equalTo: centerXAnchor), + centerYConstraint: textView.centerYAnchor.constraint(equalTo: centerYAnchor), + leadingConstraint: textView.leadingAnchor.constraint(equalTo: leadingAnchor), + topConstraint: textView.topAnchor.constraint(equalTo: imageView.bottomAnchor), + trailingConstraint: textView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint: textView.bottomAnchor.constraint(lessThanOrEqualTo: controlsStackView.topAnchor)) + + controlsViewConstraints = .init( + centerXConstraint: controlsStackView.centerXAnchor.constraint(equalTo: centerXAnchor), + leadingConstraint: controlsStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + topConstraint: controlsStackView.topAnchor.constraint(equalTo: textView.bottomAnchor), + trailingConstraint: controlsStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint: controlsStackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + widthConstraint: controlsStackView.widthAnchor.constraint(equalToConstant: .zero), + heightConstraint: controlsStackView.heightAnchor.constraint(equalToConstant: .zero)) + + NSLayoutConstraint.activate( + (imageViewConstraints?.constraints ?? []) + + (textViewConstraints?.constraints ?? []) + + (controlsViewConstraints?.constraints ?? []) + ) + + NSLayoutConstraint.deactivate( + (imageViewConstraints?.sizeConstraints ?? []) + + (controlsViewConstraints?.sizeConstraints ?? []) + ) + } + + open override func bindViews() { + super.bindViews() + + keyboardDidShownObserver = NotificationCenter.default + .addObserver(forName: UIResponder.keyboardDidShowNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.configureLayoutForKeyboard(notification, isKeyboardHidden: false) + } + + keyboardDidHiddenObserver = NotificationCenter.default + .addObserver(forName: UIResponder.keyboardDidHideNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.configureLayoutForKeyboard(notification, isKeyboardHidden: true) + } + } + + open override func configureAppearance() { + super.configureAppearance() + + controlsStackView.distribution = .equalSpacing + } + + // MARK: - Open methods + + open func applyBaseStyle(style: BasePlaceholderStyle & ViewAppearance>) { + textView.configure(with: style.titleSubtitle) + controlsStackView.axis = style.controlsViewAxis + + style.buttonsStyles.forEach { + let button = StatefulButton(type: .custom) + + button.set(titles: $0.titles) + button.set(images: $0.images) + button.set(appearance: $0.appearance) + + if let action = $0.action { + button.addTarget(action.target, action: action.action, for: action.event) + } + + controlsStackView.addArrangedSubview(button) + } + + configureAppearance(appearance: style.appearance) + } + + open func addAction(_ action: UIButton.Action, + forButtonAtIndex index: Int) { + + let button = controlsStackView.arrangedSubviews[safe: index] as? UIButton + + button?.addTarget(action.target, action: action.action, for: action.event) + } + + open func configureAppearance(appearance: BaseAppearance) { + configureImageViewLayout(layout: appearance.imageViewAppearance.layout) + configureTextViewLayout(layout: appearance.textViewAppearance.layout) + configureControlsViewLayout(layout: appearance.controlsViewAppearance.layout) + + configureUIView(appearance: appearance) + textView.configure(appearance: appearance.textViewAppearance) + controlsStackView.configureUIView(appearance: appearance.controlsViewAppearance) + } + + open func configureLayoutForKeyboard(_ notification: Notification, isKeyboardHidden: Bool) { + let multiplier = isKeyboardHidden ? 1.0 : -1.0 + + if let height = getKeyboardHeight(notification) { + controlsViewConstraints?.bottomConstraint?.constant = multiplier * height / 2 + } + } + + // MARK: - Private methods + + private func configureImageViewLayout(layout: WrappedViewLayout) { + guard !isImageViewHidden, let imageViewConstraints = imageViewConstraints else { + NSLayoutConstraint.deactivate(imageViewConstraints?.constraints ?? []) + return + } + + configureLayout(layout: layout, constraints: imageViewConstraints) + } + + private func configureTextViewLayout(layout: WrappedViewLayout) { + if isImageViewHidden { + self.textViewConstraints?.topConstraint?.isActive = false + self.textViewConstraints?.topConstraint = textView.topAnchor.constraint(equalTo: topAnchor) + } + + if isControlsViewHidden { + self.textViewConstraints?.bottomConstraint?.isActive = false + self.textViewConstraints?.bottomConstraint = textView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor) + } + + if let textViewConstraints = textViewConstraints { + configureLayout(layout: layout, constraints: textViewConstraints) + } + } + + private func configureControlsViewLayout(layout: SpacedWrappedViewLayout) { + guard !isControlsViewHidden, let controlsViewConstraints = controlsViewConstraints else { + NSLayoutConstraint.deactivate(controlsViewConstraints?.constraints ?? []) + return + } + + configureLayout(layout: layout, constraints: controlsViewConstraints) + controlsStackView.spacing = layout.spacing + } + + private func configureLayout(layout: WrappedViewLayout, constraints: SubviewConstraints) { + layout.setupSize(widthConstraint: constraints.widthConstraint, heightConstraint: constraints.heightConstraint) + + layout.setupCenterYOffset(centerYConstraint: constraints.centerYConstraint, + topConstraint: constraints.topConstraint, + bottomConstraint: constraints.bottomConstraint) + + layout.setupCenterXOffset(centerXConstraint: constraints.centerXConstraint, + leadingConstraint: constraints.leadingConstraint, + trailingConstraint: constraints.trailingConstraint) + } + + private func getKeyboardHeight(_ notification: Notification) -> CGFloat? { + guard let userInfo = notification.userInfo else { + return nil + } + + return (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height + } +} + +// MARK: - BaseAppearance + Appearance + +extension BasePlaceholderView { + + open class BaseAppearance: UIView.BaseAppearance { + + public var imageViewAppearance: ImageViewAppearance + public var textViewAppearance: DefaultTitleSubtitleView.Appearance + public var controlsViewAppearance: UIView.DefaultSpacedWrappedAppearance + + public init(layout: UIView.DefaultWrappedLayout = .defaultLayout, + backgroundColor: UIColor = .clear, + border: UIViewBorder = .init(), + shadow: UIViewShadow? = nil, + imageViewAppearance: ImageViewAppearance = .defaultAppearance, + textViewAppearance: DefaultTitleSubtitleView.Appearance = .defaultAppearance, + controlsViewAppearance: UIView.DefaultSpacedWrappedAppearance = .defaultAppearance) { + + self.imageViewAppearance = imageViewAppearance + self.textViewAppearance = textViewAppearance + self.controlsViewAppearance = controlsViewAppearance + + super.init(layout: layout, + backgroundColor: backgroundColor, + border: border, + shadow: shadow) + } + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderImageView.swift b/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderImageView.swift new file mode 100644 index 00000000..f73677dd --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderImageView.swift @@ -0,0 +1,76 @@ +// +// 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 + +final public class DefaultPlaceholderImageView: BasePlaceholderImageView, AppearanceConfigurable { + + public enum Defaults { + public static var placeholderImageName: String { + "global_image_placeholder_icon" + } + } + + public typealias Appearance = UIView.DefaultWrappedViewHolderAppearance + + public var placeholderImage: UIImage? { + get { + placeholderView.image + } + set { + placeholderView.image = newValue + } + } + + // MARK: - Init + + public init(image: UIImage? = nil, placeholderImage: UIImage? = .init(named: Defaults.placeholderImageName)) { + super.init(image: image) + + self.placeholderImage = placeholderImage + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + initializeView() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + // MARK: - BaseInitializableViewProtocol + + public override func configureAppearance() { + super.configureAppearance() + + placeholderView.contentMode = .scaleAspectFit + } + + // MARK: - AppearanceConfigurable + + public func configure(appearance: Appearance) { + configureBasePlaceholder(appearance: appearance) + } +} diff --git a/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderView.swift b/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderView.swift new file mode 100644 index 00000000..de9761ad --- /dev/null +++ b/TIUIElements/Sources/Views/Placeholder/Views/DefaultPlaceholderView.swift @@ -0,0 +1,71 @@ +// +// 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 + +public final class DefaultPlaceholderView: BasePlaceholderView, AppearanceConfigurable { + + public override var isImageViewHidden: Bool { + super.isImageViewHidden || imageView.image == nil + } + + public func apply(style: DefaultPlaceholderStyle) { + imageView.image = style.image + + super.applyBaseStyle(style: style) + + configureImageSizeConstraints(size: imageView.image?.size ?? .zero) + } + + // MARK: - AppearanceConfigurable + + public func configure(appearance: Appearance) { + configureAppearance(appearance: appearance) + } + + // MARK: - Private methods + + private func configureImageSizeConstraints(size: CGSize) { + guard size != .zero else { + return + } + + if size.height.isFinite, size.height > .zero { + imageViewConstraints?.heightConstraint?.constant = size.height + } + + if size.width.isFinite, size.width > .zero { + imageViewConstraints?.widthConstraint?.constant = size.width + } + } +} + +// MARK: - Default appearance model + +extension DefaultPlaceholderView { + public final class Appearance: BaseAppearance, ViewAppearance { + public static var defaultAppearance: Self { + .init() + } + } +} diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Placeholder.xcplaygroundpage/Contents.swift b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Placeholder.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..19eddec8 --- /dev/null +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/Pages/Placeholder.xcplaygroundpage/Contents.swift @@ -0,0 +1,571 @@ +/*: + # Placeholder API + + _TIUIElements_ добавляет `DefaultPlaceholderView` - плейсхолдер. Он представляет собой 3 вертикально расположенные view: `UIImageView`, `DefaultTitleSubtitleView`, `UIStackView`. + + > `UIStackView` используется для добавления кнопок в различном количестве и с необходимым расположением (горизонтальным/вертикальным) + + ## Принцип работы + + Для создания и конфигурации плейсхолдера существует фабрика `PlaceholderFactory`. Для этого следует воспользоваться методом `createImageStylePlaceholder(_:)`. + */ +import TIUIElements +import TIUIKitCore +import UIKit + +let factory = PlaceholderFactory() +let defaultPlaceholderView = factory.createLoadingDataErrorPlaceholder() + +/*: + ## Конфигурация + + Метод `createImageStylePlaceholder(_:)` принимает в себя аргумент типа `DefaultPlaceholderStyle`, позволяющий полностью сконфигурировать создаваемую view +*/ +let styleWithText = DefaultPlaceholderStyle(titleSubtitle: .init(title: "Server error has occured!")) +let placeholderWithErrorText = factory.createImageStylePlaceholder(styleWithText) + +/*: + Возможные опции для конфигурации: + + - картинка + - текст (_title_, _subtitle_) + - внешний вид плейсхолдера в целом и каждой отдельной view + - расположение view внутри плейсхолдера относительно друг друга + - расоложение кнопок внутри *stackView* (горизонтальное/вертикальное) + - внешний вид кнопок + - текст и картинка кнопок + - действия кнопок + + > Стоит учитывать, что если картинка плейсхолдера не указывается, то `UIImageView` будет скрыт. Тот же принцип касается и кнопок - если вы не добавили стилей кнопок, то `UIStackView` скрывается. + */ +let customStyle = DefaultPlaceholderStyle( + image: UIImage(named: "proj-placeholder-image"), + titleSubtitle: .init(title: "An error has occured", subtitle: "Please, reload the page"), + appearance: .make { placeholder in + placeholder.backgroundColor = .blue + + placeholder.textViewAppearance { textView in + textView.titleAppearance { title in + title.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + } + textView.subtitleAppearance = textView.titleAppearance + } + + }, buttonsStyles: [ + .init(titles: [.normal: "Reload"], + appearance: [ + .normal: UIButton.DefaultAppearance(textAttributes: .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false)) + ]) + ]) + +let placeholderView = factory.createImageStylePlaceholder(customStyle) + +/*: + ### Методы конфигурации + + Как видно из прошлого примера даже самая простая настройка может выглядеть очень громоздко. Для улучшения читаемости и простоты переиспользования, `DefaultPlaceholderStyle` соответствует протоколу `PlaceholderStyle`, который добавляет следующие методы для конструирования стилей плейсхолдеров: + + - `make(_:)`: статический метод для создания стиля. Принимает в себя функцию с переменной типа создоваемого стиля + - `update(_:)`: метод для изменения уже существующего стиля. Принимает в себя функцию с переменной типа создаваемого стиля + - `updateAppearance(_:)`: метод для изменения внешнего вида плейсхолдера. Принимает в себя функцию с переменной типа `ViewAppearance`. В случае `DefaultPlaceholderStyle` это `DefaultPlaceholderView.Appearance` + - `withButton(_:)`: метод для добавления новой кнопки. Принимает в себя фунцию с переменной типа `PlaceholderButtonStyle`. + - `withButtons(_:axis:_:)`: метод для добавления/изменения срузу нескольких кнопок. Первым агрументом указывается количество кнопок, вторым их расположение, третьим - функция двух переменных, где первая переменная - это номер (индекс) кнопки, вторая - `PlaceholderButtonStyle` + */ +let styleFromMake = DefaultPlaceholderStyle.make { style in + style.image = UIImage(named: "proj-placeholder-image") + style.titleSubtitle = .init(title: "An error has occured", subtitle: "Please, reload the page") +} + +let styleWithUpdatedImage = styleFromMake.update { style in + style.image = UIImage(named: "proj-other-placeholder-image") +} + +let styleWithAppearance = styleFromMake.updateAppearance { placeholder in + placeholder.backgroundColor = .blue + + placeholder.textViewAppearance { textView in + textView.titleAppearance { title in + title.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + } + + textView.subtitleAppearance = textView.titleAppearance + } +} + +let styleWithButton = styleWithAppearance.withButton { buttonStyle in + buttonStyle.titles = [.normal: "Reload"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] +} + +//: Стоит обратить внимание на то, что если бы метод использовался на `styleWithButton`, то у уже добавленной кнопки (она была бы с индексом 0) сохранился стиль, так что его можно было либо дополнить, либо переопределить +let styleWithTwoButtons = styleWithAppearance.withButtons(2, axis: .vertical) { index, buttonStyle in + if index == .zero { + buttonStyle.titles = [.normal: "Reload"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] + } + + if index == 1 { + buttonStyle.titles = [.normal: "Wait"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] + } +} + +/*: + ### Стандартные стили + + Для удобства у `PlaceholderFactory` есть несколько стандартных стилей, которые можно использовать для ускоренной разработки и изменять под свои нужды при необходимости: + + - `errorStyle` + - `loadingDataErrorStyle` + - `emptyStateStyle` + + У `PlaceholderFactory` есть готовые методы для создания плейсхолдера с такими стилями + */ + +//: ![image](resources/errorStyle-img.png) +let errorStylePlaceholder = factory.createErrorPlaceholder() + +//: ![image](resources/loadingDataErrorStyle-img.png) +let loadingDataErrorStylePlaceholder = factory.createLoadingDataErrorPlaceholder() + +//: ![image](resources/emptyStateStyle-img.png) +let emptyStateStylePlaceholder = factory.createEmptyStatePlaceholder() + +/*: + ### Кастомизация стандартных стилей + + Стили `errorStyle`, `loadingDataErrorStyle`, `emptyStateStyle` могут показывать, лежащие в **Assets**. Для этого необходимо только называть картинки: _placeholder_error_icon_, _placeholder_loading_data_icon_, _placeholder_empty_state_icon_ соответственно. Если нужной картинки не будет, то она просто не отобразится в плейсхолдере. При таком изменении никаких дополнительных настроек делать не нужно. + + Также при создании фабрики, можно передать в нее объект соответствующий протоколу `PlaceholderLocalizationProvider` для добавления необходимого текста. + + При добавлении иных изменений нужно либо делать наследника `PlaceholderFactory`, либо передавать стиль через метод `createImageStylePlaceholder(_:)`. В обоих случаях изменить стандартный стиль можно через соответствующие методы фабрики: + + - `errorStyle()` + - `loadingDataErrorStyle()` + - `emptyStateStyle()` + */ +class CustomViewController: BaseInitializableViewController { + private var currentPlaceholder: UIView? + + private var customErrorStyle: DefaultPlaceholderStyle { + factoryWithCustomErrorStyle.errorStyle() + .update { style in + style.withButtons(1, axis: .vertical) { _, buttonStyle in + buttonStyle.action = (target: nil, action: #selector(closePlaceholder), event: .touchUpInside) + } + } + } + + let factoryWithCustomErrorStyle = PlaceholderFactory() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // some activity with error result... + + let currentPlaceholder = factoryWithCustomErrorStyle.createImageStylePlaceholder(customErrorStyle) + + // custom presentation of the placeholder... + + self.currentPlaceholder = currentPlaceholder + } + + @objc private func closePlaceholder() { + currentPlaceholder?.removeFromSuperview() + } +} + +/*: + ## Использование плейсхолдеров без фабрики + + Если необходимо использовать заглушки без фабрики, то их конфигурацию можно доверить методу `apply(style:)` у каждого `DefaultPlaceholderView` + */ +class PlaceholderHolderViewController: BaseInitializableViewController, ConfigurableView { + + private let placeholder = DefaultPlaceholderView() + + override func addViews() { + super.addViews() + + view.addSubview(placeholder) + } + + override func configureLayout() { + super.configureLayout() + + placeholder.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + placeholder.leadingAnchor.constraint(equalTo: view.leadingAnchor), + placeholder.topAnchor.constraint(equalTo: view.topAnchor), + placeholder.trailingAnchor.constraint(equalTo: view.trailingAnchor), + placeholder.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func configureAppearance() { + super.configureAppearance() + + placeholder.isHidden = true + } + + func configure(with error: ErrorType) { + switch error { + case .internetConnection: + placeholder.apply(style: Self.internetConnectionErrorStyle) + + case .unknown: + placeholder.apply(style: Self.unknownErrorStyle) + } + + placeholder.isHidden = false + } +} + +extension PlaceholderHolderViewController { + static var internetConnectionErrorStyle: DefaultPlaceholderStyle { + factory.errorStyle().update { _ in + // some configurations + } + } + + static var unknownErrorStyle: DefaultPlaceholderStyle { + factory.errorStyle().update { _ in + // some configurations + } + } +} + +enum ErrorType { + case internetConnection + case unknown +} + +import PlaygroundSupport + +let placeholder = PlaceholderHolderViewController() + +PlaygroundPage.current.liveView = placeholder + +placeholder.configure(with: .internetConnection) + +/*: + ## Создание кастомных заглушек + + Если необходимо показывать что-то кроме `UIImageView`, можно создать наследника `BasePlaceholderView`. + + В качестве примера показан заглушка с lottie анимацией: + + ```swift + import Lottie + + public final class LottiePlaceholderStyle: BasePlaceholderStyle, PlaceholderStyle { + + public static var defaultStyle: LottiePlaceholderStyle { + .init() + } + + public var animationName: String + public var animationSpeed: CGFloat + public var loopMode: LottieLoopMode + + public init(titleSubtitle: DefaultTitleSubtitleViewModel = .init(), + appearance: LottiePlaceholderView.Appearance = .defaultAppearance, + controlsViewAxis: NSLayoutConstraint.Axis = .vertical, + buttonsStyles: [PlaceholderButtonStyle] = [], + animationName: String = "", + animationSpeed: CGFloat = 1, + loopMode: LottieLoopMode = .loop) { + + self.animationName = animationName + self.animationSpeed = animationSpeed + self.loopMode = loopMode + + super.init(titleSubtitle: titleSubtitle, + appearance: appearance, + controlsViewAxis: controlsViewAxis, + buttonsStyles: buttonsStyles) + } + } + + public final class LottiePlaceholderView: BasePlaceholderView { + public override var isImageViewHidden: Bool { + super.isImageViewHidden || imageView.animation == nil + } + + public func apply(style: LottiePlaceholderStyle) { + imageView.animation = LottieAnimation.named(style.animationName) + imageView.animationSpeed = style.animationSpeed + imageView.loopMode = style.loopMode + imageView.play() + + super.applyBaseStyle(style: style) + + configureImageSizeConstraints(size: imageView.animation?.size ?? .zero) + } + + public func configure(appearance: Appearance) { + configureAppearance(appearance: appearance) + } + + private func configureImageSizeConstraints(size: CGSize) { + guard size != .zero else { + return + } + + if size.height.isFinite, size.height > .zero { + imageViewConstraints?.widthConstraint?.constant = size.height + } + + if size.width.isFinite, size.width > .zero { + imageViewConstraints?.widthConstraint?.constant = size.width + } + } + } + + extension LottiePlaceholderView { + public final class Appearance: BaseAppearance, ViewAppearance { + public static var defaultAppearance: Self { + .init() + } + } + } + + class LottieAnimationViewController: BaseViewController { + let placeholderFactory = PlaceholderFactory() + + static var lottieStyle: LottiePlaceholderStyle { + .make { + $0.animationName = "cat" + $0.titleSubtitle = .init(title: "Long time no see, Nyan cat") + } + .updateAppearance { + $0.imageViewAppearance.layout { + $0.size = .fixedHeight(250) + } + + $0.textViewAppearance { + $0.titleAppearance { + $0.textAttributes = .init(font: .boldSystemFont(ofSize: 25), color: .systemPink, alignment: .center, isMultiline: false) + } + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + showPlaceholder() + } + + func showPlaceholder() { + let placeholder = LottiePlaceholderView() + placeholder.apply(style: Self.lottieStyle) + + placeholder.frame = view.frame + view.addSubview(placeholder) + } + } + ``` + + ## Плейсхолдеры для UIImageView + + Вместе с полноразмерными заглушками была добавлена новая `UIImageView`, способная отображать картинку-плейсхолдер пока не присвоена image + */ +let placeholderImage = UIImage(named: "placeholder-image") + +//: Теперь при использовании данного imageView будет отображаться картинка, созданная выше +let placeholderImageView = DefaultPlaceholderImageView(placeholderImage: placeholderImage) + +//: Здесь все еще отображается `placeholderImage` +placeholderImageView.image = nil + +//: Здесь placeholderImage спрячится +placeholderImageView.image = UIImage(named: "image") + +//: При обнулении картинки `placeholderImage` покажется заново +placeholderImageView.image = nil + +//: > При этом необязательно создавать картинку плейсхолдера отдельно. Создайте картинку с именем _global_image_placeholder_icon_ в Assets каталоге и она сама подгрузится в `DefaultPlaceholderImageView` при использовании инициализатора `init(image:placeholderImage:)` + +/*: + ### Пример контроллера + + ![image](resources/image-placeholder-view-controller.png) + + ```swift + import TITableKitUtils + import TableKit + + class WorkingCatView: BaseInitializableView, ConfigurableView, AppearanceConfigurable { + + typealias ViewModel = (image: UIImage?, title: String?, subtitle: String?) + + private let catImageView = DefaultPlaceholderImageView() + private let catLabel = DefaultTitleSubtitleView() + + override func addViews() { + super.addViews() + + addSubviews(catImageView, catLabel) + } + + override func configureLayout() { + super.configureLayout() + + [catImageView, catLabel] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + catImageView.topAnchor.constraint(equalTo: topAnchor, constant: 20), + catImageView.centerXAnchor.constraint(equalTo: centerXAnchor), + catImageView.heightAnchor.constraint(equalToConstant: 90), + catImageView.widthAnchor.constraint(equalToConstant: 90), + + catLabel.topAnchor.constraint(equalTo: catImageView.bottomAnchor, constant: 20), + catLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + catLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + catLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20), + ]) + } + + override func configureAppearance() { + super.configureAppearance() + + catImageView.contentMode = .scaleAspectFit + } + + func configure(with viewModel: ViewModel) { + catImageView.image = viewModel.image + catLabel.configure(with: .init(title: viewModel.title, subtitle: viewModel.subtitle)) + catImageView.placeholderImage = UIImage.gifImageWithName("cat-loader") + } + + func configure(appearance: Appearance) { + catImageView.configure(appearance: appearance.catImageAppearance) + catLabel.configure(appearance: appearance.catLabelAppearance) + configureUIView(appearance: appearance) + } + } + + extension WorkingCatView { + final class Appearance: UIView.BaseWrappedAppearance, WrappedViewAppearance { + static var defaultAppearance: Self { + .init() + } + + var catImageAppearance: DefaultPlaceholderImageView.Appearance + var catLabelAppearance: DefaultTitleSubtitleView.Appearance + + public init(layout: UIView.DefaultWrappedLayout = .defaultLayout, + backgroundColor: UIColor = .clear, + border: UIViewBorder = .init(), + shadow: UIViewShadow? = nil, + catImageAppearance: DefaultPlaceholderImageView.Appearance = .defaultAppearance, + catLabelAppearance: DefaultTitleSubtitleView.Appearance = .defaultAppearance) { + + self.catImageAppearance = catImageAppearance + self.catLabelAppearance = catLabelAppearance + + super.init(layout: layout, + backgroundColor: backgroundColor, + border: border, + shadow: shadow) + } + } + } + + class CatsViewController: BaseViewController { + + typealias ImageRow = WorkingCatView.InTableRow + + let viewModels: [WorkingCatView.ViewModel] = [ + (image: UIImage(named: "cat-worker"), title: "Pusic", subtitle: "C++ dev"), + (image: nil, title: "Luke", subtitle: "Jedi"), // image can't be loaded + (image: .petDog, title: "Marzia", subtitle: "HR"), + (image: .petFox, title: "Fox", subtitle: "iOS Dev") + ] + + override func viewDidLoad() { + super.viewDidLoad() + + // I swear it's a network request, images are loading ;) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in + + let rows = (self?.viewModels ?? []).compactMap { + self?.createRow($0) + } + self?.tableDirector.replace(withRows: rows) + } + } + + override func bindViews() { + super.bindViews() + + tableDirector += viewModels.map { + createRow((nil, $0.title, $0.subtitle)) + } + } + + private func createRow(_ viewModel: WorkingCatView.ViewModel) -> ImageRow { + ImageRow(item: viewModel) + .with(appearance: Self.rowAppearance) + } + } + + extension CatsViewController { + static var rowAppearance: ImageRow.Appearance { + .make { row in + row.subviewAppearance { container in + container.layout.insets = .edges(16) + container.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.3) + container.border = .init(color: .black, width: 1, cornerRadius: 10, roundedCorners: .allCorners) + + container.catImageAppearance = Self.imageAppearance + container.catLabelAppearance = Self.textAppearance + } + } + } + + static var imageAppearance: DefaultPlaceholderImageView.Appearance { + .make { + $0.border = .init(cornerRadius: 12, roundedCorners: .allCorners) + $0.subviewAppearance.update { + $0.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.5) + } + } + } + + static var textAppearance: DefaultTitleSubtitleView.Appearance { + .make { + $0.titleAppearance.textAttributes = .init(font: .systemFont(ofSize: 25), color: .black, alignment: .center, isMultiline: false) + $0.subtitleAppearance.textAttributes = .init(font: .italicSystemFont(ofSize: 18), color: .gray, alignment: .center, isMultiline: false) + } + } + } + ``` + */ 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 index bd63e083..5a8a9f4c 100644 --- 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 @@ -144,7 +144,7 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { - форма - отступы - При этом все view делятся на: + При этом все view делятся на: - `UIView` с subviews (контейнеры) - `UIView` без subviews - `UILabel` @@ -156,9 +156,9 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { ### Анимация `SkeletonsConfiguration` для настройки анимации принимает тип `(SkeletonsLayer) -> CAAnimationGroup`. Это означает, что при необходимости вы можете создать любую необходимую анимацию. Анимация будет применена к каждому скелетону. - + Однако для удобства существует уже определенный класс `SkeletonsAnimationBuilder` со статическим методом `createDirectionalGradientAnimation(_:)` для создания анимаций в одну из сторон: - + ```swift public enum SkeletonsAnimationDirection { case leftToRight @@ -172,12 +172,12 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { } ``` */ -let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in +let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .leftToRight, duration: 1.5) return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) }) -let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in +let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .topToBottom, duration: 1.5) return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) }) @@ -248,7 +248,7 @@ var confWithPadding: SkeletonsConfiguration { ## Что если нужно больше? Если стандартной настройки не хватает, то в конфигуратор можно передать объект, соответствующий протоколу `SkeletonsConfigurationDelegate` через который можно настроить слой скелетона для каждой вью отдельно - + ```swift public protocol SkeletonsConfigurationDelegate: AnyObject { func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType) diff --git a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground index 3debe4b3..6e3097f4 100644 --- a/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground +++ b/TIUIElements/TIUIElements.app/Contents/MacOS/TIUIElements.playground/contents.xcplayground @@ -1,2 +1,7 @@ - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/TIUIElements/TIUIElements.podspec b/TIUIElements/TIUIElements.podspec index 929a4d54..8ab4a684 100644 --- a/TIUIElements/TIUIElements.podspec +++ b/TIUIElements/TIUIElements.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIElements' - s.version = '1.39.0' + s.version = '1.40.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/TIUIKitCore.podspec b/TIUIKitCore/TIUIKitCore.podspec index b62c7eb6..7c6bf57d 100644 --- a/TIUIKitCore/TIUIKitCore.podspec +++ b/TIUIKitCore/TIUIKitCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIKitCore' - s.version = '1.39.0' + s.version = '1.40.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 9e3654b8..3d7826d6 100644 --- a/TIWebView/TIWebView.podspec +++ b/TIWebView/TIWebView.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIWebView' - s.version = '1.39.0' + s.version = '1.40.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 d832db7e..f2905293 100644 --- a/TIYandexMapUtils/TIYandexMapUtils.podspec +++ b/TIYandexMapUtils/TIYandexMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIYandexMapUtils' - s.version = '1.39.0' + s.version = '1.40.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/placeholder.md b/docs/tiuielements/placeholder.md new file mode 100644 index 00000000..b65767a0 --- /dev/null +++ b/docs/tiuielements/placeholder.md @@ -0,0 +1,598 @@ + +# Placeholder API + + _TIUIElements_ добавляет `DefaultPlaceholderView` - плейсхолдер. Он представляет собой 3 вертикально расположенные view: `UIImageView`, `DefaultTitleSubtitleView`, `UIStackView`. + + > `UIStackView` используется для добавления кнопок в различном количестве и с необходимым расположением (горизонтальным/вертикальным) + +## Принцип работы + + Для создания и конфигурации плейсхолдера существует фабрика `PlaceholderFactory`. Для этого следует воспользоваться методом `createImageStylePlaceholder(_:)`. + +```swift +import TIUIElements +import TIUIKitCore +import UIKit + +let factory = PlaceholderFactory() +let defaultPlaceholderView = factory.createLoadingDataErrorPlaceholder() +``` + +## Конфигурация + + Метод `createImageStylePlaceholder(_:)` принимает в себя аргумент типа `DefaultPlaceholderStyle`, позволяющий полностью сконфигурировать создаваемую view + +```swift +let styleWithText = DefaultPlaceholderStyle(titleSubtitle: .init(title: "Server error has occured!")) +let placeholderWithErrorText = factory.createImageStylePlaceholder(styleWithText) +``` + + Возможные опции для конфигурации: + + - картинка + - текст (_title_, _subtitle_) + - внешний вид плейсхолдера в целом и каждой отдельной view + - расположение view внутри плейсхолдера относительно друг друга + - расоложение кнопок внутри *stackView* (горизонтальное/вертикальное) + - внешний вид кнопок + - текст и картинка кнопок + - действия кнопок + + > Стоит учитывать, что если картинка плейсхолдера не указывается, то `UIImageView` будет скрыт. Тот же принцип касается и кнопок - если вы не добавили стилей кнопок, то `UIStackView` скрывается. + +```swift +let customStyle = DefaultPlaceholderStyle( + image: UIImage(named: "proj-placeholder-image"), + titleSubtitle: .init(title: "An error has occured", subtitle: "Please, reload the page"), + appearance: .make { placeholder in + placeholder.backgroundColor = .blue + + placeholder.textViewAppearance { textView in + textView.titleAppearance { title in + title.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + } + textView.subtitleAppearance = textView.titleAppearance + } + + }, buttonsStyles: [ + .init(titles: [.normal: "Reload"], + appearance: [ + .normal: UIButton.DefaultAppearance(textAttributes: .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false)) + ]) + ]) + +let placeholderView = factory.createImageStylePlaceholder(customStyle) +``` + +### Методы конфигурации + + Как видно из прошлого примера даже самая простая настройка может выглядеть очень громоздко. Для улучшения читаемости и простоты переиспользования, `DefaultPlaceholderStyle` соответствует протоколу `PlaceholderStyle`, который добавляет следующие методы для конструирования стилей плейсхолдеров: + + - `make(_:)`: статический метод для создания стиля. Принимает в себя функцию с переменной типа создоваемого стиля + - `update(_:)`: метод для изменения уже существующего стиля. Принимает в себя функцию с переменной типа создаваемого стиля + - `updateAppearance(_:)`: метод для изменения внешнего вида плейсхолдера. Принимает в себя функцию с переменной типа `ViewAppearance`. В случае `DefaultPlaceholderStyle` это `DefaultPlaceholderView.Appearance` + - `withButton(_:)`: метод для добавления новой кнопки. Принимает в себя фунцию с переменной типа `PlaceholderButtonStyle`. + - `withButtons(_:axis:_:)`: метод для добавления/изменения срузу нескольких кнопок. Первым агрументом указывается количество кнопок, вторым их расположение, третьим - функция двух переменных, где первая переменная - это номер (индекс) кнопки, вторая - `PlaceholderButtonStyle` + +```swift +let styleFromMake = DefaultPlaceholderStyle.make { style in + style.image = UIImage(named: "proj-placeholder-image") + style.titleSubtitle = .init(title: "An error has occured", subtitle: "Please, reload the page") +} + +let styleWithUpdatedImage = styleFromMake.update { style in + style.image = UIImage(named: "proj-other-placeholder-image") +} + +let styleWithAppearance = styleFromMake.updateAppearance { placeholder in + placeholder.backgroundColor = .blue + + placeholder.textViewAppearance { textView in + textView.titleAppearance { title in + title.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + } + + textView.subtitleAppearance = textView.titleAppearance + } +} + +let styleWithButton = styleWithAppearance.withButton { buttonStyle in + buttonStyle.titles = [.normal: "Reload"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] +} +``` + +Стоит обратить внимание на то, что если бы метод использовался на `styleWithButton`, то у уже добавленной кнопки (она была бы с индексом 0) сохранился стиль, так что его можно было либо дополнить, либо переопределить + +```swift +let styleWithTwoButtons = styleWithAppearance.withButtons(2, axis: .vertical) { index, buttonStyle in + if index == .zero { + buttonStyle.titles = [.normal: "Reload"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] + } + + if index == 1 { + buttonStyle.titles = [.normal: "Wait"] + buttonStyle.appearance = [.normal: UIButton.DefaultAppearance.make { button in + button.textAttributes = .init(font: .systemFont(ofSize: 20), + color: .black, + alignment: .natural, + isMultiline: false) + }] + } +} +``` + +### Стандартные стили + + Для удобства у `PlaceholderFactory` есть несколько стандартных стилей, которые можно использовать для ускоренной разработки и изменять под свои нужды при необходимости: + + - `errorStyle` + - `loadingDataErrorStyle` + - `emptyStateStyle` + + У `PlaceholderFactory` есть готовые методы для создания плейсхолдера с такими стилями + +![image](resources/errorStyle-img.png) + +```swift +let errorStylePlaceholder = factory.createErrorPlaceholder() +``` + +![image](resources/loadingDataErrorStyle-img.png) + +```swift +let loadingDataErrorStylePlaceholder = factory.createLoadingDataErrorPlaceholder() +``` + +![image](resources/emptyStateStyle-img.png) + +```swift +let emptyStateStylePlaceholder = factory.createEmptyStatePlaceholder() +``` + +### Кастомизация стандартных стилей + + Стили `errorStyle`, `loadingDataErrorStyle`, `emptyStateStyle` могут показывать, лежащие в **Assets**. Для этого необходимо только называть картинки: _placeholder_error_icon_, _placeholder_loading_data_icon_, _placeholder_empty_state_icon_ соответственно. Если нужной картинки не будет, то она просто не отобразится в плейсхолдере. При таком изменении никаких дополнительных настроек делать не нужно. + + Также при создании фабрики, можно передать в нее объект соответствующий протоколу `PlaceholderLocalizationProvider` для добавления необходимого текста. + + При добавлении иных изменений нужно либо делать наследника `PlaceholderFactory`, либо передавать стиль через метод `createImageStylePlaceholder(_:)`. В обоих случаях изменить стандартный стиль можно через соответствующие методы фабрики: + + - `errorStyle()` + - `loadingDataErrorStyle()` + - `emptyStateStyle()` + +```swift +class CustomViewController: BaseInitializableViewController { + private var currentPlaceholder: UIView? + + private var customErrorStyle: DefaultPlaceholderStyle { + factoryWithCustomErrorStyle.errorStyle() + .update { style in + style.withButtons(1, axis: .vertical) { _, buttonStyle in + buttonStyle.action = (target: nil, action: #selector(closePlaceholder), event: .touchUpInside) + } + } + } + + let factoryWithCustomErrorStyle = PlaceholderFactory() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // some activity with error result... + + let currentPlaceholder = factoryWithCustomErrorStyle.createImageStylePlaceholder(customErrorStyle) + + // custom presentation of the placeholder... + + self.currentPlaceholder = currentPlaceholder + } + + @objc private func closePlaceholder() { + currentPlaceholder?.removeFromSuperview() + } +} +``` + +## Использование плейсхолдеров без фабрики + + Если необходимо использовать заглушки без фабрики, то их конфигурацию можно доверить методу `apply(style:)` у каждого `DefaultPlaceholderView` + +```swift +class PlaceholderHolderViewController: BaseInitializableViewController, ConfigurableView { + + private let placeholder = DefaultPlaceholderView() + + override func addViews() { + super.addViews() + + view.addSubview(placeholder) + } + + override func configureLayout() { + super.configureLayout() + + placeholder.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + placeholder.leadingAnchor.constraint(equalTo: view.leadingAnchor), + placeholder.topAnchor.constraint(equalTo: view.topAnchor), + placeholder.trailingAnchor.constraint(equalTo: view.trailingAnchor), + placeholder.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func configureAppearance() { + super.configureAppearance() + + placeholder.isHidden = true + } + + func configure(with error: ErrorType) { + switch error { + case .internetConnection: + placeholder.apply(style: Self.internetConnectionErrorStyle) + + case .unknown: + placeholder.apply(style: Self.unknownErrorStyle) + } + + placeholder.isHidden = false + } +} + +extension PlaceholderHolderViewController { + static var internetConnectionErrorStyle: DefaultPlaceholderStyle { + factory.errorStyle().update { _ in + // some configurations + } + } + + static var unknownErrorStyle: DefaultPlaceholderStyle { + factory.errorStyle().update { _ in + // some configurations + } + } +} + +enum ErrorType { + case internetConnection + case unknown +} + +import PlaygroundSupport + +let placeholder = PlaceholderHolderViewController() + +PlaygroundPage.current.liveView = placeholder + +placeholder.configure(with: .internetConnection) +``` + +## Создание кастомных заглушек + + Если необходимо показывать что-то кроме `UIImageView`, можно создать наследника `BasePlaceholderView`. + + В качестве примера показан заглушка с lottie анимацией: + + ```swift + import Lottie + + public final class LottiePlaceholderStyle: BasePlaceholderStyle, PlaceholderStyle { + + public static var defaultStyle: LottiePlaceholderStyle { + .init() + } + + public var animationName: String + public var animationSpeed: CGFloat + public var loopMode: LottieLoopMode + + public init(titleSubtitle: DefaultTitleSubtitleViewModel = .init(), + appearance: LottiePlaceholderView.Appearance = .defaultAppearance, + controlsViewAxis: NSLayoutConstraint.Axis = .vertical, + buttonsStyles: [PlaceholderButtonStyle] = [], + animationName: String = "", + animationSpeed: CGFloat = 1, + loopMode: LottieLoopMode = .loop) { + + self.animationName = animationName + self.animationSpeed = animationSpeed + self.loopMode = loopMode + + super.init(titleSubtitle: titleSubtitle, + appearance: appearance, + controlsViewAxis: controlsViewAxis, + buttonsStyles: buttonsStyles) + } + } + + public final class LottiePlaceholderView: BasePlaceholderView { + public override var isImageViewHidden: Bool { + super.isImageViewHidden || imageView.animation == nil + } + + public func apply(style: LottiePlaceholderStyle) { + imageView.animation = LottieAnimation.named(style.animationName) + imageView.animationSpeed = style.animationSpeed + imageView.loopMode = style.loopMode + imageView.play() + + super.applyBaseStyle(style: style) + + configureImageSizeConstraints(size: imageView.animation?.size ?? .zero) + } + + public func configure(appearance: Appearance) { + configureAppearance(appearance: appearance) + } + + private func configureImageSizeConstraints(size: CGSize) { + guard size != .zero else { + return + } + + if size.height.isFinite, size.height > .zero { + imageViewConstraints?.widthConstraint?.constant = size.height + } + + if size.width.isFinite, size.width > .zero { + imageViewConstraints?.widthConstraint?.constant = size.width + } + } + } + + extension LottiePlaceholderView { + public final class Appearance: BaseAppearance, ViewAppearance { + public static var defaultAppearance: Self { + .init() + } + } + } + + class LottieAnimationViewController: BaseViewController { + let placeholderFactory = PlaceholderFactory() + + static var lottieStyle: LottiePlaceholderStyle { + .make { + $0.animationName = "cat" + $0.titleSubtitle = .init(title: "Long time no see, Nyan cat") + } + .updateAppearance { + $0.imageViewAppearance.layout { + $0.size = .fixedHeight(250) + } + + $0.textViewAppearance { + $0.titleAppearance { + $0.textAttributes = .init(font: .boldSystemFont(ofSize: 25), color: .systemPink, alignment: .center, isMultiline: false) + } + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + showPlaceholder() + } + + func showPlaceholder() { + let placeholder = LottiePlaceholderView() + placeholder.apply(style: Self.lottieStyle) + + placeholder.frame = view.frame + view.addSubview(placeholder) + } + } + ``` + +## Плейсхолдеры для UIImageView + + Вместе с полноразмерными заглушками была добавлена новая `UIImageView`, способная отображать картинку-плейсхолдер пока не присвоена image + +```swift +let placeholderImage = UIImage(named: "placeholder-image") +``` + +Теперь при использовании данного imageView будет отображаться картинка, созданная выше + +```swift +let placeholderImageView = DefaultPlaceholderImageView(placeholderImage: placeholderImage) +``` + +Здесь все еще отображается `placeholderImage` + +```swift +placeholderImageView.image = nil +``` + +Здесь placeholderImage спрячится + +```swift +placeholderImageView.image = UIImage(named: "image") +``` + +При обнулении картинки `placeholderImage` покажется заново + +```swift +placeholderImageView.image = nil +``` + +> При этом необязательно создавать картинку плейсхолдера отдельно. Создайте картинку с именем _global_image_placeholder_icon_ в Assets каталоге и она сама подгрузится в `DefaultPlaceholderImageView` при использовании инициализатора `init(image:placeholderImage:)` +### Пример контроллера + + ![image](resources/image-placeholder-view-controller.png) + + ```swift + import TITableKitUtils + import TableKit + + class WorkingCatView: BaseInitializableView, ConfigurableView, AppearanceConfigurable { + + typealias ViewModel = (image: UIImage?, title: String?, subtitle: String?) + + private let catImageView = DefaultPlaceholderImageView() + private let catLabel = DefaultTitleSubtitleView() + + override func addViews() { + super.addViews() + + addSubviews(catImageView, catLabel) + } + + override func configureLayout() { + super.configureLayout() + + [catImageView, catLabel] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + catImageView.topAnchor.constraint(equalTo: topAnchor, constant: 20), + catImageView.centerXAnchor.constraint(equalTo: centerXAnchor), + catImageView.heightAnchor.constraint(equalToConstant: 90), + catImageView.widthAnchor.constraint(equalToConstant: 90), + + catLabel.topAnchor.constraint(equalTo: catImageView.bottomAnchor, constant: 20), + catLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + catLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + catLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20), + ]) + } + + override func configureAppearance() { + super.configureAppearance() + + catImageView.contentMode = .scaleAspectFit + } + + func configure(with viewModel: ViewModel) { + catImageView.image = viewModel.image + catLabel.configure(with: .init(title: viewModel.title, subtitle: viewModel.subtitle)) + catImageView.placeholderImage = UIImage.gifImageWithName("cat-loader") + } + + func configure(appearance: Appearance) { + catImageView.configure(appearance: appearance.catImageAppearance) + catLabel.configure(appearance: appearance.catLabelAppearance) + configureUIView(appearance: appearance) + } + } + + extension WorkingCatView { + final class Appearance: UIView.BaseWrappedAppearance, WrappedViewAppearance { + static var defaultAppearance: Self { + .init() + } + + var catImageAppearance: DefaultPlaceholderImageView.Appearance + var catLabelAppearance: DefaultTitleSubtitleView.Appearance + + public init(layout: UIView.DefaultWrappedLayout = .defaultLayout, + backgroundColor: UIColor = .clear, + border: UIViewBorder = .init(), + shadow: UIViewShadow? = nil, + catImageAppearance: DefaultPlaceholderImageView.Appearance = .defaultAppearance, + catLabelAppearance: DefaultTitleSubtitleView.Appearance = .defaultAppearance) { + + self.catImageAppearance = catImageAppearance + self.catLabelAppearance = catLabelAppearance + + super.init(layout: layout, + backgroundColor: backgroundColor, + border: border, + shadow: shadow) + } + } + } + + class CatsViewController: BaseViewController { + + typealias ImageRow = WorkingCatView.InTableRow + + let viewModels: [WorkingCatView.ViewModel] = [ + (image: UIImage(named: "cat-worker"), title: "Pusic", subtitle: "C++ dev"), + (image: nil, title: "Luke", subtitle: "Jedi"), // image can't be loaded + (image: .petDog, title: "Marzia", subtitle: "HR"), + (image: .petFox, title: "Fox", subtitle: "iOS Dev") + ] + + override func viewDidLoad() { + super.viewDidLoad() + + // I swear it's a network request, images are loading ;) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { [weak self] in + + let rows = (self?.viewModels ?? []).compactMap { + self?.createRow($0) + } + self?.tableDirector.replace(withRows: rows) + } + } + + override func bindViews() { + super.bindViews() + + tableDirector += viewModels.map { + createRow((nil, $0.title, $0.subtitle)) + } + } + + private func createRow(_ viewModel: WorkingCatView.ViewModel) -> ImageRow { + ImageRow(item: viewModel) + .with(appearance: Self.rowAppearance) + } + } + + extension CatsViewController { + static var rowAppearance: ImageRow.Appearance { + .make { row in + row.subviewAppearance { container in + container.layout.insets = .edges(16) + container.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.3) + container.border = .init(color: .black, width: 1, cornerRadius: 10, roundedCorners: .allCorners) + + container.catImageAppearance = Self.imageAppearance + container.catLabelAppearance = Self.textAppearance + } + } + } + + static var imageAppearance: DefaultPlaceholderImageView.Appearance { + .make { + $0.border = .init(cornerRadius: 12, roundedCorners: .allCorners) + $0.subviewAppearance.update { + $0.backgroundColor = UIColor(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 0.5) + } + } + } + + static var textAppearance: DefaultTitleSubtitleView.Appearance { + .make { + $0.titleAppearance.textAttributes = .init(font: .systemFont(ofSize: 25), color: .black, alignment: .center, isMultiline: false) + $0.subtitleAppearance.textAttributes = .init(font: .italicSystemFont(ofSize: 18), color: .gray, alignment: .center, isMultiline: false) + } + } + } + ``` diff --git a/docs/tiuielements/resources/emptyStateStyle-img.png b/docs/tiuielements/resources/emptyStateStyle-img.png new file mode 100644 index 00000000..1e0f1a9c Binary files /dev/null and b/docs/tiuielements/resources/emptyStateStyle-img.png differ diff --git a/docs/tiuielements/resources/errorStyle-img.png b/docs/tiuielements/resources/errorStyle-img.png new file mode 100644 index 00000000..87fd6c2a Binary files /dev/null and b/docs/tiuielements/resources/errorStyle-img.png differ diff --git a/docs/tiuielements/resources/image-placeholder-view-controller.png b/docs/tiuielements/resources/image-placeholder-view-controller.png new file mode 100644 index 00000000..5ce0b503 Binary files /dev/null and b/docs/tiuielements/resources/image-placeholder-view-controller.png differ diff --git a/docs/tiuielements/resources/loadingDataErrorStyle-img.png b/docs/tiuielements/resources/loadingDataErrorStyle-img.png new file mode 100644 index 00000000..fab4ed6f Binary files /dev/null and b/docs/tiuielements/resources/loadingDataErrorStyle-img.png differ diff --git a/docs/tiuielements/skeletons.md b/docs/tiuielements/skeletons.md index 945c849a..0fd4d72a 100644 --- a/docs/tiuielements/skeletons.md +++ b/docs/tiuielements/skeletons.md @@ -150,7 +150,7 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { - форма - отступы - При этом все view делятся на: + При этом все view делятся на: - `UIView` с subviews (контейнеры) - `UIView` без subviews - `UILabel` @@ -162,9 +162,9 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { ### Анимация `SkeletonsConfiguration` для настройки анимации принимает тип `(SkeletonsLayer) -> CAAnimationGroup`. Это означает, что при необходимости вы можете создать любую необходимую анимацию. Анимация будет применена к каждому скелетону. - + Однако для удобства существует уже определенный класс `SkeletonsAnimationBuilder` со статическим методом `createDirectionalGradientAnimation(_:)` для создания анимаций в одну из сторон: - + ```swift public enum SkeletonsAnimationDirection { case leftToRight @@ -179,12 +179,12 @@ class CustomConfigurableSkeletonableView: UIView, SkeletonsPresenter { ``` ```swift -let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in +let confWithLeftToRightAnim = SkeletonsConfiguration(animation: { _ in let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .leftToRight, duration: 1.5) return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) }) -let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in +let confWithTopToBottomAnim = SkeletonsConfiguration(animation: { _ in let animConfig = DirectionalSkeletonsAnimationConfiguration(direction: .topToBottom, duration: 1.5) return SkeletonsAnimationBuilder.createDirectionalGradientAnimation(animConfig) }) @@ -267,7 +267,7 @@ var confWithPadding: SkeletonsConfiguration { ## Что если нужно больше? Если стандартной настройки не хватает, то в конфигуратор можно передать объект, соответствующий протоколу `SkeletonsConfigurationDelegate` через который можно настроить слой скелетона для каждой вью отдельно - + ```swift public protocol SkeletonsConfigurationDelegate: AnyObject { func layerDidConfigured(_ layer: SkeletonLayer, forViewType type: SkeletonLayer.ViewType)