// // 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 } public extension UIControl.StateKey { static var loading: Self { .init(controlState: .loading) } } 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 = [StateKey: Bool] private var backedIsLoading = false public var isLoading: Bool { get { backedIsLoading } set { guard backedIsLoading != newValue else { return } backedIsLoading = newValue if isLoading { if activityIndicator == nil { configureDefaultActivityIndicator() } activityIndicator?.startAnimating() } else { activityIndicator?.stopAnimating() } updateAppearance() } } public var additionalHitTestMargins: UIEdgeInsets = .zero public var onDisabledStateTapHandler: VoidClosure? // MARK: - Internal properties var activityIndicator: ActivityIndicator? { willSet { activityIndicator?.removeFromSuperview() } didSet { if let activityIndicator { addSubview(activityIndicator) } } } var activityIndicatorShouldCenterInView = false var stateViewModelMap: [StateKey: BaseButtonViewModel] = [:] var onStateChanged: ParameterClosure? // MARK: - Private properties private var eventPropagations: StateEventPropagations = [:] public func setEventPropagation(_ eventPropagation: Bool, for state: State) { eventPropagations[.init(controlState: state)] = eventPropagation } // MARK: - UIButton override open override func setImage(_ image: UIImage?, for state: State) { guard state != .loading else { return } super.setImage(image, for: state) } open override func title(for state: State) -> String? { stateViewModelMap[.init(controlState: state)]?.title ?? super.title(for: state) } open override func image(for state: State) -> UIImage? { stateViewModelMap[.init(controlState: state)]?.image ?? super.image(for: state) } open override func backgroundImage(for state: State) -> UIImage? { stateViewModelMap[.init(controlState: state)]?.backgroundImage ?? super.backgroundImage(for: state) } // MARK: - UIControl override open override var state: State { if isLoading { return super.state.union(.loading) } else { return super.state } } override open var isEnabled: Bool { get { super.isEnabled } set { guard isEnabled != newValue else { return } super.isEnabled = newValue updateAppearance() } } override open var isHighlighted: Bool { get { super.isHighlighted } set { guard isHighlighted != newValue else { return } super.isHighlighted = newValue updateAppearance() } } open override var isSelected: Bool { get { super.isSelected } set { guard isSelected != newValue else { return } super.isSelected = newValue 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 ?? true) { onDisabledStateTapHandler?() } let touchEventReceiver = super.hitTest(point, with: event) let shouldPropagateEvent = (eventPropagations[.init(controlState: state)] ?? true) || isHidden if pointInsideView && touchEventReceiver == nil && !shouldPropagateEvent { return UIView() // 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]) } }