Instantly share code, notes, and snippets.
Last active
October 16, 2024 14:45
-
Star
(9)
9
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save maximkrouk/35c0ec0baf4d4e797786f60c49a2554e to your computer and use it in GitHub Desktop.
Easily customisable declarative UIKit button
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// - Depends on https://github.com/swift-declarative-configuration | |
// - Depends on https://github.com/swift-foundation-extensions | |
// - Depends on https://gist.github.com/maximkrouk/942125396a857e49203ddb933d557c31 | |
import UIKit | |
import FoundationExtensions | |
import DeclarativeConfiguration | |
fileprivate extension UIView { | |
func pinToSuperview() { | |
guard let superview = superview else { return } | |
translatesAutoresizingMaskIntoConstraints = false | |
NSLayoutConstraint.activate([ | |
topAnchor.constraint(equalTo: superview.topAnchor), | |
bottomAnchor.constraint(equalTo: superview.bottomAnchor), | |
leadingAnchor.constraint(equalTo: superview.leadingAnchor), | |
trailingAnchor.constraint(equalTo: superview.trailingAnchor), | |
]) | |
} | |
} | |
public final class Button<Content: UIView>: UIView { | |
// MARK: - Properties | |
private let control = Control() | |
public let content: Content | |
public let overlay = UIView { $0 | |
.backgroundColor(.clear) | |
.alpha(0) | |
} | |
private var contentPressResettable: Resettable<Content>! | |
private var contentDisableResettable: Resettable<Content>! | |
private var overlayPressResettable: Resettable<UIView>! | |
private var overlayDisableResettable: Resettable<UIView>! | |
public var pressStyle: StyleManager<PressConfiguration> = .default | |
public var disabledStyle: StyleManager<DisableConfiguration> = .default | |
private lazy var pressEndAnimator = UIViewPropertyAnimator() | |
private var pressEndAnimationDuration: TimeInterval = 0.4 | |
public var tapAreaOffset: (x: CGFloat, y: CGFloat) = (8, 8) | |
public var haptic: Haptic? { | |
get { control.haptic } | |
set { control.haptic = newValue } | |
} | |
public var action: (() -> Void)? { | |
get { | |
control.$onAction.map { action in | |
{ action(()) } | |
} | |
} | |
set { | |
onAction(perform: newValue) | |
} | |
} | |
private var _isEnabled = true | |
public var isEnabled: Bool { | |
get { _isEnabled } | |
set { | |
_isEnabled = newValue | |
isUserInteractionEnabled = newValue | |
disabledStyle.updateStyle( | |
for: DisableConfiguration( | |
isEnabled: newValue, | |
content: contentDisableResettable, | |
overlay: overlayDisableResettable | |
) | |
) | |
} | |
} | |
// MARK: - Initialization | |
public convenience init(action: @escaping () -> Void = {}, content: () -> Content) { | |
self.init(content: content(), action: action) | |
} | |
public convenience init(action: @escaping () -> Void) { | |
self.init(content: .init(), action: action) | |
} | |
public convenience init() { | |
self.init(frame: .zero) | |
self.configure() | |
} | |
public init(content: Content, action: @escaping () -> Void = {}) { | |
self.content = content | |
super.init(frame: .zero) | |
self.control.onAction(perform: action) | |
self.configure() | |
} | |
public override init(frame: CGRect) { | |
self.content = .init() | |
super.init(frame: frame) | |
configure() | |
} | |
public required init?(coder: NSCoder) { | |
self.content = .init() | |
super.init(coder: coder) | |
configure() | |
} | |
// MARK: - Hit test | |
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { | |
return bounds | |
.insetBy(dx: -tapAreaOffset.x, dy: -tapAreaOffset.y) | |
.contains(point) | |
} | |
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { | |
guard let view = super.hitTest(point, with: event) else { return nil } | |
if view == self { return control } | |
return view | |
} | |
// MARK: Initial configuration | |
private func configure() { | |
content.removeFromSuperview() | |
control.removeFromSuperview() | |
setContentCompressionResistancePriority(.defaultHigh, for: .vertical) | |
setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) | |
addSubview(content) | |
addSubview(overlay) | |
addSubview(control) | |
content.pinToSuperview() | |
overlay.pinToSuperview() | |
control.pinToSuperview() | |
contentPressResettable = Resettable(content) | |
contentDisableResettable = Resettable(content) | |
overlayPressResettable = Resettable(overlay) | |
overlayDisableResettable = Resettable(overlay) | |
control.onPressBegin { [weak self] in | |
self?.animatePressBegin() | |
} | |
control.onPressEnd { [weak self] in | |
self?.animatePressEnd() | |
} | |
} | |
@discardableResult | |
public func onAction(perform action: (() -> Void)?) -> Button { | |
control.onAction(perform: action.map { action in | |
{ _ in action() } | |
}) | |
return self | |
} | |
@discardableResult | |
public func modifier(_ modifier: Modifier) -> Button { | |
modifier.config.configured(self) | |
} | |
@discardableResult | |
public func pressStyle(_ styleManager: StyleManager<PressConfiguration>) -> Button { | |
builder.pressStyle(styleManager).build() | |
} | |
@discardableResult | |
public func disabledStyle(_ styleManager: StyleManager<DisableConfiguration>) -> Button { | |
builder.disabledStyle(styleManager).build() | |
} | |
// MARK: Animation | |
private func animatePressBegin() { | |
pressEndAnimator.stopAnimation(true) | |
pressStyle.updateStyle( | |
for: PressConfiguration( | |
isPressed: true, | |
content: contentPressResettable, | |
overlay: overlayPressResettable | |
) | |
) | |
} | |
private func animatePressEnd() { | |
pressEndAnimator = UIViewPropertyAnimator(duration: pressEndAnimationDuration, curve: .easeOut, animations: { | |
self.pressStyle.updateStyle( | |
for: PressConfiguration( | |
isPressed: false, | |
content: self.contentPressResettable, | |
overlay: self.overlayPressResettable | |
) | |
) | |
}) | |
pressEndAnimator.startAnimation() | |
} | |
// MARK: UIControl Handler | |
private class Control: UIControl { | |
@Handler<Void> | |
var onPressBegin | |
@Handler<Void> | |
var onPressEnd | |
@Handler<Void> | |
var onAction | |
var haptic: Haptic? | |
convenience init( | |
action: @escaping () -> Void, | |
onPressBegin: @escaping () -> Void, | |
onPressEnd: @escaping () -> Void | |
) { | |
self.init() | |
self.onAction(perform: action) | |
self.onPressBegin(perform: onPressBegin) | |
self.onPressEnd(perform: onPressEnd) | |
self.configure() | |
} | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
configure() | |
} | |
required init?(coder: NSCoder) { | |
super.init(coder: coder) | |
configure() | |
} | |
private func configure() { | |
addTarget(self, action: #selector(pressBegin), for: [.touchDown, .touchDragEnter]) | |
addTarget(self, action: #selector(pressEnd), for: [.touchUpInside, .touchDragExit, .touchCancel]) | |
addTarget(self, action: #selector(runAction), for: [.touchUpInside]) | |
} | |
@objc private func pressBegin() { | |
_onPressBegin() | |
} | |
@objc private func pressEnd() { | |
_onPressEnd() | |
} | |
@objc private func runAction() { | |
_onAction() | |
haptic?.trigger() | |
} | |
} | |
} | |
// MARK: - Button<UILabel> | |
extension Button where Content == UILabel { | |
public convenience init(_ title: String, action: @escaping () -> Void = {}) { | |
self.init(action: action) { | |
UILabel { $0 | |
.numberOfLines(0) | |
.text(title) | |
.textAlignment(.center) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// - Depends on https://github.com/capturcontext/swift-declarative-configuration | |
import UIKit | |
import DeclarativeConfiguration | |
extension Button { | |
public struct Modifier { | |
let config: Config | |
public init(_ config: (Config) -> Config) { | |
self.config = Config(config: config) | |
} | |
public func apply(to button: Button) { | |
config.configure(button) | |
} | |
} | |
public struct DisableConfiguration { | |
internal init( | |
isEnabled: Bool, | |
content: Resettable<Content>, | |
overlay: Resettable<UIView> | |
) { | |
self.isEnabled = isEnabled | |
self.content = content | |
self.overlay = overlay | |
} | |
public let isEnabled: Bool | |
public let content: Resettable<Content> | |
public let overlay: Resettable<UIView> | |
} | |
public struct PressConfiguration { | |
internal init( | |
isPressed: Bool, | |
content: Resettable<Content>, | |
overlay: Resettable<UIView> | |
) { | |
self.isPressed = isPressed | |
self.content = content | |
self.overlay = overlay | |
} | |
public let isPressed: Bool | |
public let content: Resettable<Content> | |
public let overlay: Resettable<UIView> | |
} | |
public struct StyleManager<Configuration> { | |
private let updateStyleForConfiguration: (Configuration) -> Void | |
public init(update: @escaping (Configuration) -> Void) { | |
self.updateStyleForConfiguration = update | |
} | |
func updateStyle(for configuration: Configuration) -> Void { | |
updateStyleForConfiguration(configuration) | |
} | |
} | |
} | |
extension Button.StyleManager where Configuration == Button.DisableConfiguration { | |
public static var `default`: Self { .alpha(0.5) } | |
public static var none: Self { .init { _ in } } | |
public static func alpha(_ value: CGFloat) -> Self { | |
.init { configuration in | |
!configuration.isEnabled | |
? configuration.content { $0.alpha(value) } | |
: configuration.content.reset() | |
} | |
} | |
public static func darken(_ modifier: CGFloat) -> Self { | |
.init { configuration in | |
configuration.overlay.backgroundColor(.black, shouldRegisterReset: false) | |
!configuration.isEnabled | |
? configuration.overlay { $0.alpha(modifier) } | |
: configuration.overlay.reset() | |
} | |
} | |
public static func lighten(_ modifier: CGFloat) -> Self { | |
.init { configuration in | |
configuration.overlay.backgroundColor(.white, shouldRegisterReset: false) | |
!configuration.isEnabled | |
? configuration.overlay { $0.alpha(modifier) } | |
: configuration.overlay.reset() | |
} | |
} | |
public static func scale(_ modifier: CGFloat) -> Self { | |
.init { config in | |
config.isEnabled | |
? config.content { $0.transform(.init(scaleX: modifier, y: modifier)) } | |
: config.content.reset() | |
} | |
} | |
} | |
extension Button.StyleManager where Configuration == Button.PressConfiguration { | |
public static var `default`: Self { .alpha(0.2) } | |
public static var none: Self { .init { _ in } } | |
public static func alpha(_ value: CGFloat) -> Self { | |
.init { configuration in | |
configuration.isPressed | |
? configuration.content { $0.alpha(value) } | |
: configuration.content.reset() | |
} | |
} | |
public static func darken(_ modifier: CGFloat) -> Self { | |
.init { configuration in | |
configuration.overlay.backgroundColor(.black, shouldRegisterReset: false) | |
configuration.isPressed | |
? configuration.overlay { $0.alpha(modifier) } | |
: configuration.overlay.reset() | |
} | |
} | |
public static func lighten(_ modifier: CGFloat) -> Self { | |
.init { configuration in | |
configuration.overlay.backgroundColor(.white, shouldRegisterReset: false) | |
configuration.isPressed | |
? configuration.overlay { $0.alpha(modifier) } | |
: configuration.overlay.reset() | |
} | |
} | |
public static func scale(_ modifier: CGFloat) -> Self { | |
.init { config in | |
config.isPressed | |
? config.content { $0.transform(.init(scaleX: modifier, y: modifier)) } | |
: config.content.reset() | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage
Also, you can extract configurations into modifiers (and extract press styles as well)
and use them as
Back to index