LeadKit/TIUIElements/Sources/Views/StatefulButton/StatefulButton.swift

254 lines
7.5 KiB
Swift

//
// Copyright (c) 2020 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 extension UIControl.State {
// All the bits from 17 - 24 (from 1 << 16 until 1 << 23) are there for your application to use
// while 25 - 32 (from 1 << 24 until 1 << 31) are there for internal frameworks to use.
// https://developer.apple.com/documentation/uikit/uicontrolstate/uicontrolstateapplication?language=objc
// https://developer.apple.com/documentation/uikit/uicontrolstate/uicontrolstatereserved?language=objc
// https://stackoverflow.com/a/43760213
static var loading = Self(rawValue: 1 << 16 | Self.disabled.rawValue) // includes disabled state
}
open class StatefulButton: BaseInitializableButton {
public enum ActivityIndicatorPosition {
case beforeTitle(padding: CGFloat)
case center
}
@available(iOS 15.0, *)
public enum ActivityIndicatorPlacement {
case placement(NSDirectionalRectEdge, padding: CGFloat)
case center
}
public typealias StateEventPropagations = [State: Bool]
public var isLoading = false {
didSet {
if isLoading {
if activityIndicator == nil {
configureDefaultActivityIndicator()
}
updateAppearance(to: .loading)
activityIndicator?.startAnimating()
} else {
activityIndicator?.stopAnimating()
}
}
}
public var additionalHitTestMargins: UIEdgeInsets = .zero
public var onDisabledStateTapHandler: VoidClosure?
// MARK: - Internal properties
var activityIndicator: ActivityIndicator? {
willSet {
activityIndicator?.removeFromSuperview()
}
didSet {
if let activityIndicator = activityIndicator {
addSubview(activityIndicator)
}
}
}
var activityIndicatorShouldCenterInView = false
var onStateChanged: ParameterClosure<State>?
// MARK: - Private properties
private var eventPropagations: StateEventPropagations = [:]
public func setEventPropagation(_ eventPropagation: Bool, for state: State) {
eventPropagations[state] = eventPropagation
}
// MARK: - UIButton override
open override func setImage(_ image: UIImage?, for state: UIControl.State) {
guard state != .loading else {
return
}
super.setImage(image, for: state)
}
// MARK: - UIControl override
open override var state: UIControl.State {
if isLoading {
return super.state.union(.loading)
} else {
return super.state
}
}
override open var isEnabled: Bool {
didSet {
updateAppearance()
}
}
override open var isHighlighted: Bool {
didSet {
updateAppearance()
}
}
open override var isSelected: Bool {
didSet {
updateAppearance()
}
}
// MARK: - UIView override
open override func layoutSubviews() {
super.layoutSubviews()
guard let activityIndicator, isLoading else {
return
}
if activityIndicatorShouldCenterInView {
let indicatorSize = activityIndicatorSize
activityIndicator.frame = CGRect(origin: CGPoint(x: bounds.midX - indicatorSize.width / 2,
y: bounds.midY - indicatorSize.height / 2),
size: indicatorSize)
} else {
activityIndicator.frame = imageView?.frame ?? .zero
}
}
open override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let insetBounds = CGRect(x: bounds.minX - additionalHitTestMargins.left,
y: bounds.minY - additionalHitTestMargins.top,
width: bounds.width + additionalHitTestMargins.right,
height: bounds.height + additionalHitTestMargins.bottom)
return super.point(inside: point, with: event)
|| insetBounds.contains(point)
}
open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pointInsideView = self.point(inside: point, with: event)
if !isEnabled && pointInsideView, event?.allTouches?.isEmpty == false {
onDisabledStateTapHandler?()
}
let touchEventReceiver = super.hitTest(point, with: event)
let shouldPropagateEvent = (eventPropagations[state] ?? true) || isHidden
if pointInsideView && touchEventReceiver == nil && !shouldPropagateEvent {
return self // disable propagation
}
return touchEventReceiver
}
// MARK: - Public
open func configure(activityIndicator: ActivityIndicator) {
self.activityIndicator = activityIndicator
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
setNeedsLayout()
}
open func configureDefaultActivityIndicator() {
let systemIndicator: UIActivityIndicatorView
if #available(iOS 13.0, *) {
systemIndicator = UIActivityIndicatorView(style: .medium)
} else {
systemIndicator = UIActivityIndicatorView(style: .gray)
}
configure(activityIndicator: systemIndicator)
}
open func apply(state: State) {
isSelected = state.isSelected
isEnabled = !state.isDisabled
isHighlighted = state.isHightlightted
isLoading = state.isLoading
}
// MARK: - Internal
var activityIndicatorSize: CGSize {
activityIndicator?.intrinsicContentSize ?? .zero
}
// MARK: - Private
private func updateAppearance() {
if isLoading {
updateAppearance(to: .loading)
} else if isEnabled {
if isHighlighted {
updateAppearance(to: .highlighted)
} else {
updateAppearance(to: .normal)
}
} else {
updateAppearance(to: .disabled)
}
}
private func updateAppearance(to state: State) {
onStateChanged?(state)
}
}
private extension UIControl.State {
var isHightlightted: Bool {
contains([.highlighted])
}
var isSelected: Bool {
contains([.selected])
}
var isDisabled: Bool {
contains([.disabled])
}
var isLoading: Bool {
contains([.loading])
}
}