Reactions/Sources/ReactionButton.swift

265 lines
9.4 KiB
Swift

/*
* Reactions
*
* Copyright 2016-present Yannick Loriot.
* http://yannickloriot.com
*
* 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 UIKit
/**
A `ReactionButton` object is a control that executes a reaction in response to user interactions.
You can tap a reaction button in order to highlight/unhighlight a reaction. You can also make a long press to the button to display a `ReactionSelector` if you have attached one.
You can configure/skin the button using a `ReactionButtonConfig`.
*/
public final class ReactionButton: UIReactionControl {
private let iconImageView: UIImageView = Components.reactionButton.facebookLikeIcon()
private let titleLabel: UILabel = Components.reactionButton.facebookLikeLabel()
private lazy var overlay: UIView = UIView().build {
$0.clipsToBounds = false
$0.backgroundColor = .clear
$0.alpha = 0
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(ReactionButton.dismissReactionSelector)))
}
/**
A Boolean value indicating whether the reaction button is in the selected state.
*/
public override var isSelected: Bool {
didSet { update() }
}
/**
The reaction button configuration.
*/
public var config: ReactionButtonConfig = ReactionButtonConfig() {
didSet { update() }
}
/**
The reaction used to build the button.
The reaction `title` fills the button one, and the `alternativeIcon` is used to display the icon. If the `alternativeIcon` is nil, the `icon` is used instead.
*/
public var reaction = Reaction.facebook.like {
didSet { update() }
}
/**
The attached selector that the button will use in order to choose a reaction.
There are two ways to display the selector: calling the `presentReactionSelector` method or by doing a long press to the button.
*/
public var reactionSelector: ReactionSelector? {
didSet { setupReactionSelect(old: oldValue) }
}
// MARK: - Building Object
override func setup() {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(ReactionButton.tapAction)))
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(ReactionButton.longPressAction)))
addSubview(iconImageView)
addSubview(titleLabel)
}
private func setupReactionSelect(old: ReactionSelector?) {
if let selector = reactionSelector {
overlay.addSubview(selector)
}
old?.removeFromSuperview()
old?.removeTarget(self, action: #selector(ReactionButton.reactionTouchedInsideAction), for: .touchUpInside)
old?.removeTarget(self, action: #selector(ReactionButton.reactionTouchedOutsideAction), for: .touchUpOutside)
reaction = reactionSelector?.reactions.first ?? Reaction.facebook.like
reactionSelector?.addTarget(self, action: #selector(ReactionButton.reactionTouchedInsideAction), for: .touchUpInside)
reactionSelector?.addTarget(self, action: #selector(ReactionButton.reactionTouchedOutsideAction), for: .touchUpOutside)
}
// MARK: - Updating Object State
override func update() {
titleLabel.font = config.font
let iconSize = min(bounds.width - config.spacing, bounds.height) - config.iconMarging * 2
let titleSize = titleLabel.sizeThatFits(CGSize(width: bounds.width - iconSize, height: bounds.height))
var iconFrame = CGRect(x: 0, y: (bounds.height - iconSize) / 2, width: iconSize, height: iconSize)
var titleFrame = CGRect(x: iconSize + config.spacing, y: 0, width: titleSize.width, height: bounds.height)
if config.alignment == .right {
iconFrame.origin.x = bounds.width - iconSize
titleFrame.origin.x = bounds.width - iconSize - config.spacing - titleSize.width
}
else if config.alignment == .centerLeft || config.alignment == .centerRight {
let emptyWidth = bounds.width - iconFrame.width - titleLabel.bounds.width - config.spacing
if config.alignment == .centerLeft {
iconFrame.origin.x = emptyWidth / 2
titleFrame.origin.x = emptyWidth / 2 + iconSize + config.spacing
}
else {
iconFrame.origin.x = emptyWidth / 2 + titleSize.width + config.spacing
titleFrame.origin.x = emptyWidth / 2
}
}
iconImageView.image = reaction.alternativeIcon ?? reaction.icon
titleLabel.text = reaction.title
iconImageView.frame = iconFrame
titleLabel.frame = titleFrame
UIView.transition(with: titleLabel, duration: 0.15, options: .transitionCrossDissolve, animations: { [unowned self] in
self.iconImageView.tintColor = self.isSelected ? self.reaction.color : self.config.neutralTintColor
self.titleLabel.textColor = self.isSelected ? self.reaction.color : self.config.neutralTintColor
}, completion: nil)
}
// MARK: - Responding to Gesture Events
func tapAction(_ gestureRecognizer: UITapGestureRecognizer) {
isSelected = !isSelected
if isSelected {
UIView.animateKeyframes(withDuration: 0.3, delay: 0, options: .calculationModeCubic, animations: { [weak self] in
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5, animations: {
self?.iconImageView.transform = CGAffineTransform(scaleX: 1.8, y: 1.8)
})
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5, animations: {
self?.iconImageView.transform = .identity
})
}, completion: nil)
}
sendActions(for: .touchUpInside)
}
private var isLongPressMoved = false
func longPressAction(_ gestureRecognizer: UILongPressGestureRecognizer) {
guard let selector = reactionSelector else { return }
if gestureRecognizer.state == .began {
isLongPressMoved = false
displayReactionSelector(feedback: .slideFingerAcross)
}
if gestureRecognizer.state == .changed {
isLongPressMoved = true
selector.longPressAction(gestureRecognizer)
}
else if gestureRecognizer.state == .ended {
if isLongPressMoved {
selector.longPressAction(gestureRecognizer)
dismissReactionSelector()
}
else {
selector.feedback = .tapToSelectAReaction
}
}
}
// MARK: - Responding to Select Events
func reactionTouchedInsideAction(_ sender: ReactionSelector) {
guard let selectedReaction = sender.selectedReaction else { return }
let isReactionChanged = reaction != selectedReaction
reaction = selectedReaction
isSelected = true
if isReactionChanged {
sendActions(for: .valueChanged)
}
dismissReactionSelector()
}
func reactionTouchedOutsideAction(_ sender: ReactionSelector) {
dismissReactionSelector()
}
// MARK: - Presenting Reaction Selectors
/**
Presents the attached reaction selector.
If no reaction selector is attached, the method does nothing.
*/
public func presentReactionSelector() {
displayReactionSelector(feedback: .tapToSelectAReaction)
}
/**
Dismisses the attached reaction selector that was presented by the button.
*/
public func dismissReactionSelector() {
reactionSelector?.feedback = nil
animateOverlay(alpha: 0, center: CGPoint(x: overlay.bounds.midX, y: overlay.bounds.midY))
}
private func displayReactionSelector(feedback: ReactionFeedback) {
guard let selector = reactionSelector, let window = UIApplication.shared.keyWindow else { return }
if overlay.superview == nil {
UIApplication.shared.keyWindow?.addSubview(overlay)
}
overlay.frame = CGRect(x:0 , y: 0, width: window.bounds.width, height: window.bounds.height * 2)
let centerPoint = convert(CGPoint(x: bounds.midX, y: 0), to: nil)
selector.frame = selector.boundsToFit()
selector.center = centerPoint
if selector.frame.origin.x - config.spacing < 0 {
selector.center = CGPoint(x: centerPoint.x - selector.frame.origin.x + config.spacing, y: centerPoint.y)
}
else if selector.frame.origin.x + selector.frame.width + config.spacing > overlay.bounds.width {
selector.center = CGPoint(x: centerPoint.x - (selector.frame.origin.x + selector.frame.width + config.spacing - overlay.bounds.width), y: centerPoint.y)
}
selector.feedback = feedback
animateOverlay(alpha: 1, center: CGPoint(x: overlay.bounds.midX, y: overlay.bounds.midY - selector.bounds.height))
}
private func animateOverlay(alpha: CGFloat, center: CGPoint) {
UIView.animate(withDuration: 0.1) { [weak self] in
guard let overlay = self?.overlay else { return }
overlay.alpha = alpha
overlay.center = center
}
}
}