Merge branch 'feature/placeholder_api' into 'master'

feat: placeholder api

See merge request touchinstinct/LeadKit!11
This commit is contained in:
Nikita Semenov 2023-03-28 12:22:55 +00:00
commit 29d7a6ca65
42 changed files with 2298 additions and 33 deletions

View File

@ -1,5 +1,10 @@
# Changelog
### 1.40.0
- **Added**: `PlaceholderFactory` for creating `DefaultPlaceholderView` views
- **Added**: `DefaultPlaceholderImageView`
### 1.39.0
- **Added**: UIButton Appearance model

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -138,6 +138,10 @@ extension UIView {
}
}
open class BaseWrappedAppearance<Layout: WrappedViewLayout>: BaseAppearance<Layout> {
}
public final class DefaultWrappedViewHolderAppearance<SubviewAppearance: WrappedViewAppearance,
Layout: ViewLayout>: BaseWrappedViewHolderAppearance<SubviewAppearance, Layout>,
WrappedViewHolderAppearance {
@ -146,7 +150,7 @@ extension UIView {
}
}
public final class DefaultWrappedAppearance: BaseAppearance<DefaultWrappedLayout>, WrappedViewAppearance {
public final class DefaultWrappedAppearance: BaseWrappedAppearance<DefaultWrappedLayout>, WrappedViewAppearance {
public static var defaultAppearance: Self {
Self()
}

View File

@ -0,0 +1,52 @@
//
// Copyright (c) 2023 Touch Instinct
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the Software), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
import 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 }
}
}

View File

@ -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
}
}

View File

@ -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 }
}

View File

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

View File

@ -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<Appearance: ViewAppearance> {
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
}
}

View File

@ -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<DefaultPlaceholderView.Appearance>,
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)
}
}

View File

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

View File

@ -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>) -> Self {
make(builder)
}
static func make(_ builder: ParameterClosure<Self>) -> Self {
let style = Self.defaultStyle
builder(style)
return style
}
func callAsFunction(_ builder: ParameterClosure<Self>) -> Self {
update(builder)
}
func update(_ builder: ParameterClosure<Self>) -> Self {
builder(self)
return self
}
func updateAppearance(_ builder: ParameterClosure<PlaceholderAppearance>) -> Self {
builder(appearance)
return self
}
func withButton(_ builder: ParameterClosure<PlaceholderButtonStyle>) -> 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..<amount {
if let buttonStyle = buttonsStyles[safe: index] {
builder(index, buttonStyle)
} else {
let buttonStyle = PlaceholderButtonStyle()
builder(index, buttonStyle)
buttonsStyles.append(buttonStyle)
}
}
return self
}
}

View File

@ -0,0 +1,124 @@
//
// 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 BasePlaceholderImageView<Placeholder: UIView>: 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<UIView.DefaultWrappedAppearance, some WrappedViewLayout>) {
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)
}
}

View File

@ -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<ImageView: UIView>: 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<some BaseAppearance<some WrappedViewAppearance> & 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<some WrappedViewAppearance>) {
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<ImageViewAppearance: WrappedViewAppearance>: UIView.BaseAppearance<UIView.DefaultWrappedLayout> {
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)
}
}
}

View File

@ -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<UIImageView>, AppearanceConfigurable {
public enum Defaults {
public static var placeholderImageName: String {
"global_image_placeholder_icon"
}
}
public typealias Appearance = UIView.DefaultWrappedViewHolderAppearance<UIView.DefaultWrappedAppearance, UIView.DefaultWrappedLayout>
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)
}
}

View File

@ -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<UIImageView>, 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<UIView.DefaultWrappedAppearance>, ViewAppearance {
public static var defaultAppearance: Self {
.init()
}
}
}

View File

@ -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<LottiePlaceholderView.Appearance>, 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<LottieAnimationView> {
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<UIView.DefaultWrappedAppearance>, ViewAppearance {
public static var defaultAppearance: Self {
.init()
}
}
}
class LottieAnimationViewController: BaseViewController<UIView, Void> {
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<UIView.DefaultWrappedLayout>, 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<TableKitTableView, Void> {
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)
}
}
}
```
*/

View File

@ -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)

View File

@ -1,2 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='6.0' target-platform='ios' display-mode='raw' buildActiveScheme='true' executeOnSourceChanges='true'/>
<playground version='6.0' target-platform='ios' display-mode='raw' buildActiveScheme='true' executeOnSourceChanges='true'>
<pages>
<page name='Skeletons'/>
<page name='Placeholder'/>
</pages>
</playground>

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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' }

View File

@ -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<LottiePlaceholderView.Appearance>, 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<LottieAnimationView> {
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<UIView.DefaultWrappedAppearance>, ViewAppearance {
public static var defaultAppearance: Self {
.init()
}
}
}
class LottieAnimationViewController: BaseViewController<UIView, Void> {
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<UIView.DefaultWrappedLayout>, 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<TableKitTableView, Void> {
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)
}
}
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

View File

@ -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)