Since Facebook introduced reactions in 2016, it became a standard in several applications as a way for users to interact with content. ReactionButton is a control that allows developers to add this functionality to their apps in an easy way.
14 | 15 | ## Features 16 | - [x] Support of Dark Mode 17 | - [x] Customizable layout using `ReactionButtonDelegateLayout` 18 | - [x] Extensible DataSource for the control 19 | - [x] Layout support for scrolling interfaces (UICollectionView/UITableView) 20 | - [x] Codable initializer for usage on storyboards 21 | - [x] Events 22 | 23 | ## Requirements 24 | * iOS 13.0+ 25 | * Swift 5.0+ 26 | 27 | ## Installation 28 | 29 | * [Installation guide](https://github.com/lojals/ReactionButton/wiki/Installation-guide) 30 | 31 | ## Usage 32 | 33 | ### 1. Basic Instance 34 | There are multiple ways to instantiate a `ReactionButton`, using a frame, storyboards, or an empty convenience initializer. 35 | 36 | #### Example Code 37 | 38 | ```swift 39 | let buttonSample = ReactionButton(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) 40 | buttonSample.dataSource = self 41 | view.addSubview(buttonSample) 42 | ``` 43 | 44 |  45 | > Images from [Trump reactionpacks style](http://www.reactionpacks.com/packs/2c1a1e41-e9e9-407a-a532-3bfdfef6b3e6). 46 | 47 | ### 2. Delegate 48 | The `ReactionButton` has a delegate to communicate events of option selection, option focus, and cancel of actions. To use it, set the `ReactionButtonDelegate` conform as a delegate. 49 | 50 | ```swift 51 | let buttonSample = ReactionButton(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) 52 | buttonSample.delegate = self 53 | view.addSubview(buttonSample) 54 | ``` 55 |  56 | > Images from [Trump reactionpacks style](http://www.reactionpacks.com/packs/2c1a1e41-e9e9-407a-a532-3bfdfef6b3e6). 57 | 58 | ### 3. Custom layout instance 59 | `ReactionButton` allows customization of the layout with the help of `ReactionButtonDelegateLayout`. To use it, please conform to that protocol and set it as delegate (Same pattern as UICollectionView). 60 | 61 | ```swift 62 | func ReactionSelectorConfiguration(_ selector: ReactionButton) -> ReactionButton.Config { 63 | ReactionButton.Config(spacing: 2, 64 | size: 30, 65 | minSize: 34, 66 | maxSize: 45, 67 | spaceBetweenComponents: 30) 68 | } 69 | ``` 70 | You can custom your selector with the following variables, used in the 71 | 72 |  73 | 74 |  75 | 76 | ## Author 77 | Jorge Ovalle, jroz9105@gmail.com 78 | -------------------------------------------------------------------------------- /ReactionButton.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "ReactionButton" 3 | s.version = "4.0.0" 4 | s.summary = "Option selector that can be used as reactions" 5 | 6 | s.description = "Totally customizable Options (Reaction) Selector based on Reactions" 7 | 8 | s.homepage = "https://github.com/lojals/ReactionButton" 9 | s.license = { :type => 'MIT', :file => 'LICENSE' } 10 | s.author = { "Jorge Ovalle" => "jroz9105@gmail.com" } 11 | s.source = { :git => "https://github.com/lojals/ReactionButton.git", :tag => s.version.to_s } 12 | s.social_media_url = 'https://github.com/lojals' 13 | 14 | s.ios.deployment_target = '13.0' 15 | s.pod_target_xcconfig = { 'SWIFT_VERSION' => '5.0' } 16 | s.swift_version = '5.0' 17 | 18 | s.source_files = 'Sources/ReactionButton/**/*' 19 | 20 | end 21 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/CGRect+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGRect+init.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge R Ovalle Z on 4/7/18. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | extension CGRect { 11 | 12 | /// Creates an instance of `CGRect` with the same width and height. 13 | /// 14 | /// - Parameters: 15 | /// - x: Position in `x` coordinate. 16 | /// - y: Position in `y` coordinate. 17 | /// - sideSize: Size of the rect side. 18 | init(x: CGFloat, y: CGFloat, sideSize: CGFloat) { 19 | self.init(origin: CGPoint(x: x, y: y), size: CGSize(sideSize: sideSize)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/CGSize+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGSize+init.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge R Ovalle Z on 4/7/18. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | extension CGSize { 11 | 12 | /// Creates an instance of `CGSize` with the same width and height. 13 | /// 14 | /// - Parameter sideSize: Size of the side. 15 | init(sideSize: CGFloat) { 16 | self.init(width: sideSize, height: sideSize) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/EmojiSelectorView.Config+rect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionButton.Config+rect.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge Ovalle on 30/10/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension ReactionButton.Config { 11 | 12 | func rect(items: Int, originalPos: CGPoint, trait: UITraitCollection) -> CGRect { 13 | var originalPos = CGPoint(x: originalPos.x, y: originalPos.y - heightForSize - 10) 14 | let option = CGFloat(items) 15 | let width = (option + 1) * spacing + self.size * option 16 | 17 | if trait.horizontalSizeClass == .compact && trait.verticalSizeClass == .regular { 18 | originalPos.x = (UIScreen.main.bounds.width - width) / 2 19 | } 20 | 21 | return CGRect(origin: originalPos, size: CGSize(width: width, height: heightForSize)) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/UIColor+Selector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Selector.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge Ovalle on 31/10/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | public static var background: UIColor = { 13 | return UIColor { (UITraitCollection: UITraitCollection) -> UIColor in 14 | if UITraitCollection.userInterfaceStyle == .dark { 15 | return UIColor.systemGray6 16 | } else { 17 | return UIColor.white 18 | } 19 | } 20 | }() 21 | 22 | public static var shadow: UIColor = { 23 | return UIColor { (UITraitCollection: UITraitCollection) -> UIColor in 24 | if UITraitCollection.userInterfaceStyle == .dark { 25 | return UIColor.clear 26 | } else { 27 | return UIColor.lightGray 28 | } 29 | } 30 | }() 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/UIView+animation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+animation.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge Ovalle on 29/10/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | static func animate(index: Int, animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil) { 12 | UIView.animate(withDuration: 0.2, delay: 0.05 * Double(index), options: .curveEaseInOut) { 13 | animations() 14 | } completion: { finished in 15 | completion?(finished) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ReactionButton/Extensions/UIView+contains.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+contains.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge Ovalle on 29/10/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | /// A function that checks if a given point is whether or not in the frame of the view. 13 | /// - Parameter point: The point to look for. 14 | /// - Returns: A boolean that represents if the point is inside the frame. 15 | func contains(_ point: CGPoint) -> Bool { 16 | point.x > frame.minX && point.x < frame.maxX && point.y > frame.minY && point.y < frame.maxY 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ReactionButton/ReactionButton+protocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionButton+protocols.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge R Ovalle Z on 4/11/18. 6 | // 7 | 8 | import UIKit 9 | 10 | /// Describes a type that is informed of events occurring within a `ReactionButton`. 11 | public protocol ReactionButtonDelegate: class { 12 | 13 | /// The user selected an option from the sender. 14 | /// 15 | /// - Parameters: 16 | /// - sender: The `ReactionButton` which is sending the action. 17 | /// - index: Index of the selected option. 18 | func ReactionSelector(_ sender: ReactionButton, didSelectedIndex index: Int) 19 | 20 | /// The user is moving through the options. 21 | /// - Parameters: 22 | /// - sender: The `ReactionButton` which is sending the action. 23 | /// - index: Index of the selected option. 24 | func ReactionSelector(_ sender: ReactionButton, didChangeFocusTo index: Int?) 25 | 26 | /// The user cancelled the option selection. 27 | /// 28 | /// - Parameter sender: The `ReactionButton` which is sending the action. 29 | func ReactionSelectorDidCancelledAction(_ sender: ReactionButton) 30 | 31 | } 32 | 33 | public protocol ReactionButtonDelegateLayout: ReactionButtonDelegate { 34 | func ReactionSelectorConfiguration(_ selector: ReactionButton) -> ReactionButton.Config 35 | } 36 | 37 | public extension ReactionButtonDelegateLayout { 38 | func ReactionSelectorConfiguration(_ selector: ReactionButton) -> ReactionButton.Config { 39 | .default 40 | } 41 | } 42 | 43 | /// Default implementation for delegate 44 | public extension ReactionButtonDelegate { 45 | func ReactionSelector(_ sender: ReactionButton, didSelectedIndex index: Int) {} 46 | func ReactionSelector(_ sender: ReactionButton, didChangeFocusTo index: Int?) {} 47 | func ReactionSelectorDidCancelledAction(_ sender: ReactionButton) {} 48 | } 49 | 50 | public protocol ReactionButtonDataSource: class { 51 | 52 | /// Asks the data source to return the number of items in the ReactionButton. 53 | func numberOfOptions(in selector: ReactionButton) -> Int 54 | 55 | /// Asks the data source for the view of the specific item. 56 | func ReactionSelector(_ selector: ReactionButton, viewForIndex index: Int) -> UIView 57 | 58 | /// Asks the data source for the name of the specific item. 59 | func ReactionSelector(_ selector: ReactionButton, nameForIndex index: Int) -> String 60 | } 61 | -------------------------------------------------------------------------------- /Sources/ReactionButton/ReactionButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionButton.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge R Ovalle Z on 2/28/16. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A type that represents the selector with options froma items. 11 | open class ReactionButton: UIButton { 12 | 13 | public weak var delegate: ReactionButtonDelegate? 14 | public weak var dataSource: ReactionButtonDataSource? 15 | 16 | private var _dataSource: ReactionButtonDataSource { 17 | guard let dataSource = dataSource else { 18 | fatalError("❌ Please set up a datasource for the ReactionButton") 19 | } 20 | return dataSource 21 | } 22 | 23 | private var selectedItem: Int? { 24 | didSet { 25 | if oldValue != selectedItem { 26 | delegate?.ReactionSelector(self, didChangeFocusTo: selectedItem) 27 | } 28 | } 29 | } 30 | 31 | private lazy var optionsBarView: UIView = { 32 | let optionsBarView = UIView(frame: .zero) 33 | optionsBarView.layer.cornerRadius = config.heightForSize/2 34 | optionsBarView.alpha = 0.3 35 | return optionsBarView 36 | }() 37 | 38 | private var config: ReactionButton.Config { 39 | guard let delegate = delegate as? ReactionButtonDelegateLayout else { 40 | return .default 41 | } 42 | return delegate.ReactionSelectorConfiguration(self) 43 | } 44 | 45 | private var rootView: UIView? { 46 | UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.rootViewController?.view 47 | } 48 | 49 | // MARK: - View lifecycle 50 | 51 | /// Creates a new instance of `ReactionButton`. 52 | public convenience init() { 53 | self.init(frame: .zero) 54 | } 55 | 56 | /// Creates a new instace of `ReactionButton`. 57 | /// 58 | /// - Parameters: 59 | /// - frame: Frame of the button will open the selector 60 | /// - config: The custom configuration for the UI components. 61 | public override init(frame: CGRect) { 62 | super.init(frame: frame) 63 | setup() 64 | } 65 | 66 | required public init?(coder aDecoder: NSCoder) { 67 | super.init(coder: aDecoder) 68 | setup() 69 | } 70 | 71 | private func setup() { 72 | addGestureRecognizer(UILongPressGestureRecognizer(target: self, 73 | action: #selector(ReactionButton.handlePress(sender:)))) 74 | } 75 | 76 | // MARK: - Visual component interaction / animation 77 | 78 | /// Function that open and expand the Options Selector. 79 | @objc private func handlePress(sender: UILongPressGestureRecognizer) { 80 | switch sender.state { 81 | case .began: 82 | expand() 83 | case .changed: 84 | let point = sender.location(in: rootView) 85 | move(point) 86 | case .ended: 87 | collapse() 88 | default: break 89 | } 90 | } 91 | 92 | private func expand() { 93 | selectedItem = nil 94 | updateOptionsView(with: UIScreen.main.traitCollection) 95 | 96 | let config = self.config 97 | rootView?.addSubview(optionsBarView) 98 | 99 | UIView.animate(withDuration: 0.2) { 100 | self.optionsBarView.alpha = 1 101 | } 102 | 103 | for i in 0..<_dataSource.numberOfOptions(in: self) { 104 | let optionFrame = CGRect(x: xPosition(for: i), y: config.heightForSize * 1.2, 105 | sideSize: config.sizeBeforeOpen) 106 | let option = _dataSource.ReactionSelector(self, viewForIndex: i) 107 | option.frame = optionFrame 108 | option.alpha = 0.6 109 | optionsBarView.addSubview(option) 110 | 111 | UIView.animate(index: i) { 112 | option.frame.origin.y = config.spacing 113 | option.alpha = 1 114 | option.frame.size = CGSize(sideSize: config.size) 115 | let sizeCenter = config.size/2 116 | option.center = CGPoint(x: optionFrame.origin.x + sizeCenter, 117 | y: config.spacing + sizeCenter) 118 | } 119 | } 120 | } 121 | 122 | private func move(_ point: CGPoint) { 123 | // Check if the point's position is inside the defined area. 124 | if optionsBarView.contains(point) { 125 | let relativeSizePerOption = optionsBarView.frame.width / CGFloat(_dataSource.numberOfOptions(in: self)) 126 | focusOption(withIndex: Int(round((point.x - optionsBarView.frame.minX) / relativeSizePerOption))) 127 | } else { 128 | selectedItem = nil 129 | UIView.animate(withDuration: 0.2) { 130 | for (idx, view) in self.optionsBarView.subviews.enumerated() { 131 | view.frame = CGRect(x: self.xPosition(for: idx), y: self.config.spacing, sideSize: self.config.size) 132 | } 133 | } 134 | } 135 | } 136 | 137 | /// Function that collapse and close the Options Selector. 138 | private func collapse() { 139 | for (index, option) in optionsBarView.subviews.enumerated() { 140 | UIView.animate(index: index) { 141 | option.alpha = 0 142 | option.frame.size = CGSize(sideSize: self.config.sizeBeforeOpen) 143 | } completion: { finished in 144 | guard finished, index == self._dataSource.numberOfOptions(in: self)/2 else { 145 | return 146 | } 147 | self.optionsBarView.removeFromSuperview() 148 | self.optionsBarView.subviews.forEach { $0.removeFromSuperview() } 149 | if let selectedItem = self.selectedItem { 150 | self.delegate?.ReactionSelector(self, didSelectedIndex: selectedItem) 151 | } else { 152 | self.delegate?.ReactionSelectorDidCancelledAction(self) 153 | } 154 | } 155 | } 156 | } 157 | 158 | /// When a user in focusing an option, that option should magnify. 159 | /// 160 | /// - Parameter index: The index of the option in the items. 161 | private func focusOption(withIndex index: Int) { 162 | guard (0..<_dataSource.numberOfOptions(in: self)).contains(index) else { return } 163 | selectedItem = index 164 | let config = self.config 165 | var xCarry: CGFloat = index != 0 ? config.spacing : 0 166 | 167 | UIView.animate(withDuration: 0.2) { 168 | for (i, optionView) in self.optionsBarView.subviews.enumerated() { 169 | optionView.frame = CGRect(x: xCarry, y: config.spacing, sideSize: config.minSize) 170 | optionView.center.y = config.heightForSize/2 171 | switch i { 172 | case (index-1): 173 | xCarry += config.minSize 174 | case index: 175 | optionView.frame = CGRect(x: xCarry, y: -config.maxSize/2, sideSize: config.maxSize) 176 | xCarry += config.maxSize 177 | default: 178 | xCarry += config.minSize + config.spacing 179 | } 180 | } 181 | } 182 | } 183 | 184 | /// Calculate the `x` position for a given items option. 185 | /// 186 | /// - Parameter option: the position of the option in the items. <0... items.count>. 187 | /// - Returns: The x position for a given option. 188 | private func xPosition(for option: Int) -> CGFloat { 189 | let option = CGFloat(option) 190 | return (option + 1) * config.spacing + config.size * option 191 | } 192 | 193 | private func updateOptionsView(with trait: UITraitCollection) { 194 | let originPoint = superview?.convert(frame.origin, to: rootView) ?? .zero 195 | 196 | optionsBarView.backgroundColor = UIColor.background 197 | optionsBarView.layer.shadowColor = UIColor.shadow.cgColor 198 | optionsBarView.layer.shadowOpacity = 0.5 199 | optionsBarView.layer.shadowOffset = .zero 200 | 201 | optionsBarView.frame = config.rect(items: _dataSource.numberOfOptions(in: self), 202 | originalPos: originPoint, 203 | trait: trait) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Sources/ReactionButton/ReactionButtonConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionButtonConfig.swift 3 | // ReactionButton 4 | // 5 | // Created by Jorge R Ovalle Z on 4/6/18. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | public extension ReactionButton { 11 | /// A type representing the basic configurations for a `ReactionButton`. 12 | struct Config { 13 | 14 | /// The space between options. 15 | let spacing: CGFloat 16 | 17 | /// The default size for an option. 18 | let size: CGFloat 19 | 20 | /// The size of an option before expand. 21 | let sizeBeforeOpen: CGFloat 22 | 23 | /// The minimum size when an option is being selected. 24 | let minSize: CGFloat 25 | 26 | /// The maximum size when the option is beign selected. 27 | let maxSize: CGFloat 28 | 29 | var heightForSize: CGFloat { 30 | size + 2 * spacing 31 | } 32 | 33 | /// Creates an instance of `JOReactionableConfig` 34 | /// 35 | /// - Parameters: 36 | /// - spacing: The space between options. 37 | /// - size: The default size for an option. 38 | /// - minSize: The minimum size when an option is being selected. 39 | /// - maxSize: The maximum size when the option is beign selected. 40 | /// - spaceBetweenComponents: The space between the `SelectorView` and the `InformationView`. 41 | public init(spacing: CGFloat, size: CGFloat, minSize: CGFloat, maxSize: CGFloat) { 42 | self.spacing = spacing 43 | self.size = size 44 | self.minSize = minSize 45 | self.maxSize = maxSize 46 | self.sizeBeforeOpen = 10 47 | } 48 | 49 | /// A `default` definition of `ReactionButton.Config`. 50 | public static let `default` = Config(spacing: 6, 51 | size: 40, 52 | minSize: 34, 53 | maxSize: 80) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import JOEmojiableBtnTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += JOEmojiableBtnTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ReactionButtonTests/ReactionButtonTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ReactionButton 3 | 4 | final class ReactionButtonTests: XCTestCase { 5 | func testExample() { 6 | } 7 | 8 | static var allTests = [ 9 | ("testExample", testExample), 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /Tests/ReactionButtonTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(JOEmojiableBtnTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /_Pods.xcodeproj: -------------------------------------------------------------------------------- 1 | Example/Pods/Pods.xcodeproj --------------------------------------------------------------------------------