From 3cb0f46533fa51d1e1ee35b45b0f881ad5ceab99 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 6 Jul 2024 10:42:51 -0700 Subject: [PATCH] Add hover effects to poll view Closes #503 --- Tusker/Views/Poll/PollOptionView.swift | 7 ++ Tusker/Views/Poll/PollOptionsView.swift | 100 ++++++++++++++++++------ Tusker/Views/Poll/StatusPollView.swift | 1 + 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/Tusker/Views/Poll/PollOptionView.swift b/Tusker/Views/Poll/PollOptionView.swift index 52831d60..851e8b7a 100644 --- a/Tusker/Views/Poll/PollOptionView.swift +++ b/Tusker/Views/Poll/PollOptionView.swift @@ -14,6 +14,7 @@ class PollOptionView: UIView { private static let minHeight: CGFloat = 35 private static let cornerRadius = 0.1 * minHeight 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! @Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { @@ -33,6 +34,12 @@ class PollOptionView: UIView { private var labelLeadingToSelfConstraint: NSLayoutConstraint! private var fillViewWidthConstraint: NSLayoutConstraint? + var hovered: Bool = false { + didSet { + backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor + } + } + init() { super.init(frame: .zero) diff --git a/Tusker/Views/Poll/PollOptionsView.swift b/Tusker/Views/Poll/PollOptionsView.swift index 2355e90d..4abf7d62 100644 --- a/Tusker/Views/Poll/PollOptionsView.swift +++ b/Tusker/Views/Poll/PollOptionsView.swift @@ -24,9 +24,10 @@ class PollOptionsView: UIControl { private var poll: Poll! private var animator: UIViewPropertyAnimator! private var currentSelectedOptionIndex: Int? + private var currentHoveredOptionIndex: Int? - private let animationDuration: TimeInterval = 0.1 - private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) + static let animationDuration: TimeInterval = 0.1 + static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) #if !os(visionOS) private let generator = UISelectionFeedbackGenerator() @@ -59,6 +60,8 @@ class PollOptionsView: UIControl { stack.topAnchor.constraint(equalTo: topAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized))) } 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 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) { currentSelectedOptionIndex = index - animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { - view.transform = self.scaledTransform + if animator?.isRunning == true { + animator.stopAnimation(true) + } + animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) { + view.transform = Self.scaledTransform + view.hovered = true } animator.startAnimation() @@ -151,27 +172,21 @@ class PollOptionsView: UIControl { override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) - var newIndex: Int? = nil - 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 - } - } + let newIndex = optionView(at: location)?.1 if newIndex != currentSelectedOptionIndex { 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() { - view.transform = index == newIndex ? self.scaledTransform : .identity + view.transform = index == newIndex ? Self.scaledTransform : .identity + view.hovered = index == newIndex } } + animator.startAnimation() #if !os(visionOS) if newIndex != nil { @@ -190,14 +205,15 @@ class PollOptionsView: UIControl { func selectOption() { guard let index = currentSelectedOptionIndex else { return } let option = options[index] - animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { + animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) { option.transform = .identity + option.hovered = false self.selectOption(option) } animator.startAnimation() } - if animator.isRunning { + if animator?.isRunning == true { animator.addCompletion { (_) in selectOption() } @@ -208,12 +224,52 @@ class PollOptionsView: UIControl { override func cancelTracking(with event: UIEvent?) { 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 { 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 + } } } diff --git a/Tusker/Views/Poll/StatusPollView.swift b/Tusker/Views/Poll/StatusPollView.swift index 56485ff0..f09dfafd 100644 --- a/Tusker/Views/Poll/StatusPollView.swift +++ b/Tusker/Views/Poll/StatusPollView.swift @@ -65,6 +65,7 @@ class StatusPollView: UIView, StatusContentView { addSubview(infoLabel) voteButton = UIButton(configuration: .plain()) + voteButton.isPointerInteractionEnabled = true voteButton.translatesAutoresizingMaskIntoConstraints = false voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside) voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)