Add hover effects to poll view

Closes #503
This commit is contained in:
Shadowfacts 2024-07-06 10:42:51 -07:00
parent c367a2e9f1
commit 3cb0f46533
3 changed files with 86 additions and 22 deletions

View File

@ -14,6 +14,7 @@ class PollOptionView: UIView {
private static let minHeight: CGFloat = 35 private static let minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25) private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private static let hoveredBackgroundColor = UIColor(white: 0.35, alpha: 0.25)
private(set) var label: EmojiLabel! private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { @Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
@ -33,6 +34,12 @@ class PollOptionView: UIView {
private var labelLeadingToSelfConstraint: NSLayoutConstraint! private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint? private var fillViewWidthConstraint: NSLayoutConstraint?
var hovered: Bool = false {
didSet {
backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor
}
}
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)

View File

@ -24,9 +24,10 @@ class PollOptionsView: UIControl {
private var poll: Poll! private var poll: Poll!
private var animator: UIViewPropertyAnimator! private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int? private var currentSelectedOptionIndex: Int?
private var currentHoveredOptionIndex: Int?
private let animationDuration: TimeInterval = 0.1 static let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
#if !os(visionOS) #if !os(visionOS)
private let generator = UISelectionFeedbackGenerator() private let generator = UISelectionFeedbackGenerator()
@ -59,6 +60,8 @@ class PollOptionsView: UIControl {
stack.topAnchor.constraint(equalTo: topAnchor), stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -121,6 +124,20 @@ class PollOptionsView: UIControl {
} }
} }
private func optionView(at point: CGPoint) -> (PollOptionView, Int)? {
for (index, view) in options.enumerated() {
// don't use view.frame because it changes when a transform is applied
var frame = CGRect(x: 0, y: view.center.y - view.bounds.height / 2, width: view.bounds.width, height: view.bounds.height)
if index != options.count - 1 {
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
}
if frame.contains(point) {
return (view, index)
}
}
return nil
}
// MARK: - UIControl // MARK: - UIControl
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
@ -132,8 +149,12 @@ class PollOptionsView: UIControl {
if view.point(inside: touch.location(in: view), with: event) { if view.point(inside: touch.location(in: view), with: event) {
currentSelectedOptionIndex = index currentSelectedOptionIndex = index
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { if animator?.isRunning == true {
view.transform = self.scaledTransform animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
view.transform = Self.scaledTransform
view.hovered = true
} }
animator.startAnimation() animator.startAnimation()
@ -151,27 +172,21 @@ class PollOptionsView: UIControl {
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self) let location = touch.location(in: self)
var newIndex: Int? = nil let newIndex = optionView(at: location)?.1
for (index, view) in options.enumerated() {
// don't use view.frame because it changes when a transform is applied
var frame = CGRect(x: 0, y: view.center.y - view.bounds.height / 2, width: view.bounds.width, height: view.bounds.height)
if index != options.count - 1 {
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
}
if frame.contains(location) {
newIndex = index
break
}
}
if newIndex != currentSelectedOptionIndex { if newIndex != currentSelectedOptionIndex {
currentSelectedOptionIndex = newIndex currentSelectedOptionIndex = newIndex
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) { if animator.isRunning {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
for (index, view) in self.options.enumerated() { for (index, view) in self.options.enumerated() {
view.transform = index == newIndex ? self.scaledTransform : .identity view.transform = index == newIndex ? Self.scaledTransform : .identity
view.hovered = index == newIndex
} }
} }
animator.startAnimation()
#if !os(visionOS) #if !os(visionOS)
if newIndex != nil { if newIndex != nil {
@ -190,14 +205,15 @@ class PollOptionsView: UIControl {
func selectOption() { func selectOption() {
guard let index = currentSelectedOptionIndex else { return } guard let index = currentSelectedOptionIndex else { return }
let option = options[index] let option = options[index]
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.transform = .identity option.transform = .identity
option.hovered = false
self.selectOption(option) self.selectOption(option)
} }
animator.startAnimation() animator.startAnimation()
} }
if animator.isRunning { if animator?.isRunning == true {
animator.addCompletion { (_) in animator.addCompletion { (_) in
selectOption() selectOption()
} }
@ -208,12 +224,52 @@ class PollOptionsView: UIControl {
override func cancelTracking(with event: UIEvent?) { override func cancelTracking(with event: UIEvent?) {
super.cancelTracking(with: event) super.cancelTracking(with: event)
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) { if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
for view in self.options { for view in self.options {
view.transform = .identity view.transform = .identity
view.hovered = false
}
}
animator.startAnimation()
}
@objc private func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
guard let (option, index) = optionView(at: recognizer.location(in: self)) else {
return
}
switch recognizer.state {
case .began, .changed:
if index != currentHoveredOptionIndex {
let oldIndex = currentHoveredOptionIndex
currentHoveredOptionIndex = index
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.hovered = true
if let oldIndex {
self.options[oldIndex].hovered = false
}
}
animator.startAnimation()
}
case .ended, .cancelled:
if let currentHoveredOptionIndex {
self.currentHoveredOptionIndex = nil
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
self.options[currentHoveredOptionIndex].hovered = false
}
animator.startAnimation()
}
default:
break
} }
} }
} }
}

View File

@ -65,6 +65,7 @@ class StatusPollView: UIView, StatusContentView {
addSubview(infoLabel) addSubview(infoLabel)
voteButton = UIButton(configuration: .plain()) voteButton = UIButton(configuration: .plain())
voteButton.isPointerInteractionEnabled = true
voteButton.translatesAutoresizingMaskIntoConstraints = false voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside) voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)