254 lines
7.5 KiB
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])
|
|
}
|
|
}
|