Hiding actions in buttons with UIKit
Today’s article was inspired by this fancy list with hidden actions on Instagram that appears when user hold their finger on the share button.
We won’t reproduce the component style verbatim, but we’ll deal with its animations, composition, and study what UIKit has to offer that is not obvious at first glance.
The composition of this component is relatively simple: a primary action and a few more secondary actions in a hidden list. And all this is accompanied by animations and transformations. You can’t see it through the video, but there’s also a haptic feedback when showing the list and hovering over an action in this list.
Let’s dive into details and take each part individually.
Laying the basis
The component in the example has two types of actions:
- a primary action that is triggered when the button is pressed
- a secondary action that corresponds to the selected item in the list
Working with SwiftUI, you get used to the fact that all components can be composited with all other components. Coming back to UIKit, you try to bring similar approaches. In our case, the UIInteraction
protocol is a suitable tool. It describes an interface for interactions that can be associated with arbitrary views.
One standard example is the UIContextMenuInteraction
type, which shows a context menu with actions when held on an item.
Starting by describing a new type SecondaryActionsInteraction
and conform it to the UIInteraction
protocol. The compiler will force us to inherit from the NSObject
. Don’t be afraid, it is a standard practice for UIKit, because most of it has its roots in Objective-C.
final class SecondaryActionInteraction: NSObject, UIInteraction {
private lazy var longPressGesture = UILongPressGestureRecognizer(
target: self,
action: #selector(handleLongPress)
)
private(set) weak var view: UIView?
func willMove(to newView: UIView?) {}
func didMove(to newView: UIView?) {}
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {}
}
The willMove
and didMove
methods represent the UIInteraction
lifecycle, through them we will customise the view for secondary actions. To handle the finger-hold gesture on the mapping, we will add an instance of UILongPressGestureRecogniser
.
To display a list of secondary actions, we define an instance of UIStackView
. Its filling will be built with the help of UILabel
instances.
💡 This view can be replaced with a more similar one from the demo or any other custom view. For the sake of brevity, we will continue with the labels stack.
private lazy var actionsStackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.alignment = .center
view.spacing = 12.0
view.translatesAutoresizingMaskIntoConstraints = false
view.isUserInteractionEnabled = false
view.alpha = .zero
for title in ["👀", "🥹", "🎉"] {
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 24.0)
view.addArrangedSubview(label)
}
return view
}()
The implementation of lifecycle methods contains gesture and actions container re-binding between views.
func willMove(to newView: UIView?) {
guard let view else { return }
actionsStackView.removeFromSuperview()
view.removeGestureRecognizer(longPressGesture)
}
func didMove(to newView: UIView?) {
view = newView
guard let newView else { return }
newView.addSubview(actionsStackView)
NSLayoutConstraint.activate([
actionsStackView.leadingAnchor.constraint(equalTo: newView.leadingAnchor),
actionsStackView.bottomAnchor.constraint(equalTo: newView.bottomAnchor)
])
newView.addGestureRecognizer(longPressGesture)
}
To see the intermediate progress, we will make the stack opaque at the beginning of the gesture, and hide it back at the end.
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
case .began:
actionsStackView.alpha = 1.0
case .cancelled, .ended:
actionsStackView.alpha = .zero
default:
break
}
}
As a test bench, create an instance of UIButton
and put the interaction on it by calling the addInteraction
method.
#Preview {
let button = UIButton()
button.setImage(UIImage(systemName: "paperplane"), for: .normal)
button.addInteraction(SecondaryActionInteraction())
return button
}
💡
addInteraction
is a part ofUIView
interface, so the interaction can be added to any other view, it doesn't necessarily have to be a button.
Moving the flow
In the Instagram demo, the list of secondary actions moves diagonally from the bottom left corner to the top right corner, alongside changing its transparency.
For this purpose, we will use CGAffineTransform
. Add applyPinTransform
method to the UIView
extension. It’s implementation makes a translation on both axes to the height of the actions list.
extension UIView {
func applyPinTransform() {
transform = CGAffineTransform(
translationX: bounds.height,
y: -bounds.height
)
}
}
💡 We put
-bounds.height
, because in iOS the origin of the coordinate system is at the top left corner and extends to the opposite corners, so we need to move the element closer to the origin of the axis to shift it up.
We also not forget the animation so that the transformations we apply don’t happen choppy.
extension SecondaryActionInteraction {
func perform(_ animations: @escaping () -> Void) {
UIView.animate(
withDuration: 0.3,
delay: .zero,
usingSpringWithDamping: 1.0,
initialSpringVelocity: 0.1,
animations: animations
)
}
}
To the transparency changes, we will add transformation calls. At the beginning of the gesture the list will expand, and at the end it will shrink and return to its initial location.
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
case .began:
perform { [self] in
actionsStackView.alpha = 1.0
actionsStackView.applyPinTransform()
}
case .cancelled, .ended:
perform { [self] in
actionsStackView.alpha = .zero
actionsStackView.transform = .identity
}
default:
break
}
}
In the original animation, the list doesn’t just move out to the corner, but also scales from a point to its full size.
Adding an appropriate scaling to the transformation method by introducing the scale
parameter. We we will need to set different scale value at different stages of the gesture so it makes the configuration more convenient.
func applyPinTransform(scale: CGFloat) {
transform = CGAffineTransform(scaleX: scale, y: scale)
.translatedBy(
x: bounds.height,
y: -bounds.height
)
}
Before starting the gesture, we set the scale of the list to 0.1
so that it always starts its display compressed. Inside the animation block, we reset it to full scale, and at the end of the gesture we reset it back to 0.1
.
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
case .began:
actionsStackView.applyPinTransform(scale: 0.1)
perform { [self] in
actionsStackView.alpha = 1.0
actionsStackView.applyPinTransform(scale: 1.0)
}
case .cancelled, .ended:
perform { [self] in
actionsStackView.alpha = .zero
actionsStackView.applyPinTransform(scale: 0.1)
}
default:
break
}
}
💡 We use a value of 0.1 rather than 0.0 because with the latter, the shrink to the initial state happens without animation as the framework fails to interpolate it.
But it’s still not completely similar to the original animation. In it, the list is not scaled at its centre, but somewhere from a corner. Let’s fix this by tweaking the view’s anchorPoint
.
func applyPinTransform(
scale: CGFloat,
point scaleAnchorPoint: CGPoint = CGPoint(x: 0.1, y: 0.5)
) {
layer.anchorPoint = scaleAnchorPoint
let scale = scale != 0 ? scale : Double.leastNormalMagnitude
let xPadding = 1 / scale * (anchorPoint.x - 0.5) * bounds.width
let yPadding = 1 / scale * (anchorPoint.y - 0.5) * bounds.height
transform = CGAffineTransform(scaleX: scale, y: scale)
.translatedBy(x: xPadding, y: yPadding)
.translatedBy(
x: bounds.height,
y: -bounds.height
)
}
Changing anchorPoint
also affects the location of the view.
To compensate for the possible offset, we calculate an additional translation.
To learn more about the anchor point concept, check out the Apple’s Core Animation Programming Guide.
Interacting with secondary actions
Next we are going to breathe some life into the interaction with items from the secondary actions list. At the stage when the user moves a gesture across the screen, only the .changed
gesture state is involved. With it we will be interested in two scenarios.
The first one is when the user holds his finger on top of the action view. In this case, we memorise this particular view and apply a small transformation to it with lifting. For optimisation, we do this only when the active action view changes.
💡 The transformation to highlight the active action can be adjusted, for example by adding scaling.
The second scenario is when the user moves their finger outside the list. Then we reset the currently active action view and its transformation to put the view back in place.
final class SecondaryActionInteraction: NSObject, UIInteraction {
...
private weak var activeOptionView: UIView?
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
...
case .changed:
if let optionView = actionsStackView.arrangedSubviews.first(
where: { $0.point(inside: gesture.location(in: $0), with: nil) }
) {
guard activeOptionView != optionView else { return }
perform { [self] in
activeOptionView?.transform = .identity
activeOptionView = optionView
optionView.transform = .init(translationX: .zero, y: -10.0)
}
} else if !actionsStackView.point(
inside: gesture.location(in: actionsStackView),
with: nil
) {
perform { [self] in
activeOptionView?.transform = .identity
activeOptionView = nil
}
}
}
}
}
All that remains is to teach this interaction to work with different actions and call their respective handlers. For this purpose we define the Action
type, which holds the action title and its handler. The array of these values will be used to populate the actionsStackView
.
final class SecondaryActionInteraction: NSObject, UIInteraction {
struct Action {
let title: String
let handler: (Action) -> Void
}
private let actions: [Action]
init(actions: [Action]) {
self.actions = actions
}
...
private lazy var actionsStackView: UIStackView = {
let view = UIStackView()
...
for item in actions {
let label = UILabel()
label.text = item.title
view.addArrangedSubview(label)
}
return view
}()
}
💡 You can also add an icon instance, colour, etc. to the
Action
type.
The action will be considered triggered if the long press ends and we have an instance of activeOptionView
. Let's define a separate scenario for the .ended
state, which will check for the presence of activeOptionView
and through its index in actionsStackView
get the corresponding action to be called.
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
...
case .cancelled:
perform { [self] in
actionsStackView.alpha = .zero
actionsStackView.applyPinTransform(scale: 0.1)
}
case .ended:
perform { [self] in
let actionViews = actionsStackView.arrangedSubviews
if
let activeOptionView,
let index = actionViews.firstIndex(of: activeOptionView)
{
activeOptionView.transform = .identity
let action = actions[index]
action.handler(action)
self.activeOptionView = nil
}
actionsStackView.alpha = .zero
actionsStackView.applyPinTransform(scale: 0.1)
}
}
}
We can now populate the interaction initialiser with actions, as well as specify a handler for each specific action.
#Preview {
let button = UIButton()
button.setImage(UIImage(systemName: "paperplane"), for: .normal)
button.addInteraction(
SecondaryActionInteraction(actions: [
.init(title: "👀", handler: { print($0.title) }),
.init(title: "🥹", handler: { print($0.title) }),
.init(title: "🎉", handler: { print($0.title) }),
])
)
return button
}
Feeling the feedback
The UIImpactFeedbackGenerator
type is used when we want to engage the haptic engine. Its initialiser accepts a vibration style that differs in its strength.
In our case, it is sufficient to trigger the vibration once when the hold gesture has started, and every time the user moves his finger to a secondary action.
@objc private func handleLongPress(
_ gesture: UILongPressGestureRecognizer
) {
switch gesture.state {
case .began:
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
...
case .changed:
if let optionView = actionsStackView.arrangedSubviews.first(
where: { $0.point(inside: gesture.location(in: $0), with: nil) }
) {
guard activeOptionView != optionView else { return }
UIImpactFeedbackGenerator(style: .light).impactOccurred()
...
}
...
}
}
Conclusion
What could have been done differently? Well, for example not to use UIInteraction
, but inherit from UIControl
and the same way handle long press gesture and list view display.
Our implementation allows us to add this pin interaction to any view. It could be a plain UIView
instance.
Or some UILabel
with text.
Depending on the conditions, it may be necessary to change the initial parameters of the pin layout. Attention should also be paid to existing gestures and interaction availability to avoid ambiguous behaviour and gesture conflicts.
Thanks for reading, see you in next articles 🙌