Skip to content

Instantly share code, notes, and snippets.

@maximkrouk
Last active January 11, 2024 10:55
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save maximkrouk/35c0ec0baf4d4e797786f60c49a2554e to your computer and use it in GitHub Desktop.
Save maximkrouk/35c0ec0baf4d4e797786f60c49a2554e to your computer and use it in GitHub Desktop.
Easily customisable declarative UIKit button
// - 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)
}
}
}
}
// - 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()
}
}
}
@maximkrouk
Copy link
Author

maximkrouk commented Feb 1, 2021

Usage

import UIKit

class ViewController: UIViewController {
  let button = Button("Tap me!").configured { $0
    .frame(.init(x: 0, y: 0, width: 120, height: 44))
    .content.scope { $0
      .backgroundColor(.white)
      .clipsToBounds(true)
      .layer.cornerRadius(12)
      .layer.cornerCurve(.continuous)
    }
    .haptic(.light)
    .pressStyleManager(.init { config in
      config.isPressed
      ? config.content { $0
        .alpha(0.5)
        .layer.cornerRadius(20)
      }
      : config.content.reset()
    })
  }
  
  override func loadView() {
    view = UIView { $0.backgroundColor(.black) }
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(button)
  }
  
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    button.center = view.center
  }
}

Also, you can extract configurations into modifiers (and extract press styles as well)

extension Button.Modifier where Content: UILabel {
  static var primary: Self {
    .init { $0
      .content.scope { $0
        .backgroundColor(.white)
        .textColor(.black)
        .layer.cornerRadius(12)
      }
    }
  }

and use them as

Button("Tap me").modifier(.primary)

Mayme It'll be a part of https://github.com/maximkrouk/SweetUI 2.0.0 someday...

Back to index

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment