From 203c1852d4d0bce087358626b1c5b2b8efd498f7 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 28 May 2023 12:19:45 -0700 Subject: [PATCH] Reuse poll option views when updating status cell Fixes flicker/animation due to new option views begin added in default state and then changed back to the state of the existing view. Fixes #403 --- .../StatusEditPollView.swift | 2 +- .../Views/Poll/PollOptionCheckboxView.swift | 15 +- Tusker/Views/Poll/PollOptionView.swift | 148 ++++++++++-------- Tusker/Views/Poll/PollOptionsView.swift | 39 +++-- 4 files changed, 120 insertions(+), 84 deletions(-) diff --git a/Tusker/Screens/Status Edit History/StatusEditPollView.swift b/Tusker/Screens/Status Edit History/StatusEditPollView.swift index 3a8f8cf0..67ac9762 100644 --- a/Tusker/Screens/Status Edit History/StatusEditPollView.swift +++ b/Tusker/Screens/Status Edit History/StatusEditPollView.swift @@ -31,7 +31,7 @@ class StatusEditPollView: UIStackView, StatusContentPollView { for option in poll?.options ?? [] { // the edit poll doesn't actually include the multiple value - let icon = PollOptionCheckboxView(multiple: false) + let icon = PollOptionCheckboxView() icon.readOnly = false // this is a lie, but it's only used for stylistic changes let label = EmojiLabel() label.text = option.title diff --git a/Tusker/Views/Poll/PollOptionCheckboxView.swift b/Tusker/Views/Poll/PollOptionCheckboxView.swift index 36c1e144..ac0f31a9 100644 --- a/Tusker/Views/Poll/PollOptionCheckboxView.swift +++ b/Tusker/Views/Poll/PollOptionCheckboxView.swift @@ -9,6 +9,8 @@ import UIKit class PollOptionCheckboxView: UIView { + + private static let size: CGFloat = 20 var isChecked: Bool = false { didSet { @@ -25,16 +27,19 @@ class PollOptionCheckboxView: UIView { updateStyle() } } + var multiple: Bool = false { + didSet { + updateStyle() + } + } private let imageView: UIImageView - init(multiple: Bool) { + init() { imageView = UIImageView(image: UIImage(systemName: "checkmark")!) super.init(frame: .zero) - let size: CGFloat = 20 - layer.cornerRadius = (multiple ? 0.1 : 0.5) * size layer.cornerCurve = .continuous layer.borderWidth = 2 @@ -46,7 +51,7 @@ class PollOptionCheckboxView: UIView { NSLayoutConstraint.activate([ widthAnchor.constraint(equalTo: heightAnchor), - widthAnchor.constraint(equalToConstant: size), + widthAnchor.constraint(equalToConstant: PollOptionCheckboxView.size), imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3), imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3), @@ -64,6 +69,8 @@ class PollOptionCheckboxView: UIView { } private func updateStyle() { + layer.cornerRadius = (multiple ? 0.1 : 0.5) * PollOptionCheckboxView.size + imageView.isHidden = !isChecked if voted || readOnly { layer.borderColor = UIColor.clear.cgColor diff --git a/Tusker/Views/Poll/PollOptionView.swift b/Tusker/Views/Poll/PollOptionView.swift index 56e1c60c..9db21fa2 100644 --- a/Tusker/Views/Poll/PollOptionView.swift +++ b/Tusker/Views/Poll/PollOptionView.swift @@ -11,35 +11,43 @@ import Pachyderm 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(set) var label: EmojiLabel! - private(set) var checkbox: PollOptionCheckboxView? + @Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { + $0.translatesAutoresizingMaskIntoConstraints = false + } + var checkboxIfInitialized: PollOptionCheckboxView? { + _checkbox.valueIfInitialized + } + private var percentLabel: UILabel! + @Lazy private var fillView: UIView = UIView().configure { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .tintColor.withAlphaComponent(0.6) + $0.layer.zPosition = -1 + $0.layer.cornerRadius = PollOptionView.cornerRadius + $0.layer.cornerCurve = .continuous + } + private var labelLeadingToSelfConstraint: NSLayoutConstraint! + private var fillViewWidthConstraint: NSLayoutConstraint? - init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) { + init() { super.init(frame: .zero) - let minHeight: CGFloat = 35 - layer.cornerRadius = 0.1 * minHeight + layer.cornerRadius = PollOptionView.cornerRadius layer.cornerCurve = .continuous backgroundColor = PollOptionView.unselectedBackgroundColor - let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired - if showCheckbox { - checkbox = PollOptionCheckboxView(multiple: poll.multiple) - checkbox!.translatesAutoresizingMaskIntoConstraints = false - addSubview(checkbox!) - } - label = EmojiLabel() label.translatesAutoresizingMaskIntoConstraints = false label.numberOfLines = 0 label.font = .preferredFont(forTextStyle: .callout) - label.text = option.title - label.setEmojis(poll.emojis, identifier: poll.id) addSubview(label) + labelLeadingToSelfConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8) - let percentLabel = UILabel() + percentLabel = UILabel() percentLabel.translatesAutoresizingMaskIntoConstraints = false percentLabel.text = "100%" percentLabel.font = label.font @@ -48,6 +56,53 @@ class PollOptionView: UIView { percentLabel.setContentHuggingPriority(.required, for: .horizontal) addSubview(percentLabel) + let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: PollOptionView.minHeight) + // on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts + // this keeps autolayout from complaining + minHeightConstraint.priority = .required - 1 + + NSLayoutConstraint.activate([ + minHeightConstraint, + + label.topAnchor.constraint(equalTo: topAnchor, constant: 4), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4), + + percentLabel.topAnchor.constraint(equalTo: topAnchor), + percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + ]) + + isAccessibilityElement = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateUI(poll: Poll, option: Poll.Option, ownVoted: Bool, mastodonController: MastodonController) { + let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired + if showCheckbox { + checkbox.isChecked = ownVoted + checkbox.voted = poll.voted ?? false + + labelLeadingToSelfConstraint.isActive = false + if checkbox.superview != self { + addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.centerYAnchor.constraint(equalTo: centerYAnchor), + checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8), + ]) + } + } else if !showCheckbox { + labelLeadingToSelfConstraint.isActive = true + _checkbox.valueIfInitialized?.removeFromSuperview() + } + + label.text = option.title + label.setEmojis(poll.emojis, identifier: poll.id) + accessibilityLabel = option.title if (poll.voted ?? false) || poll.effectiveExpired, @@ -61,60 +116,27 @@ class PollOptionView: UIView { frac = poll.votesCount == 0 ? 0 : CGFloat(optionVotes) / CGFloat(poll.votesCount) } let percent = String(format: "%.0f%%", frac * 100) - + percentLabel.isHidden = false percentLabel.text = percent - - let fillView = UIView() - fillView.translatesAutoresizingMaskIntoConstraints = false - fillView.backgroundColor = .tintColor.withAlphaComponent(0.6) - fillView.layer.zPosition = -1 - fillView.layer.cornerRadius = layer.cornerRadius - fillView.layer.cornerCurve = .continuous - addSubview(fillView) - - NSLayoutConstraint.activate([ - fillView.leadingAnchor.constraint(equalTo: leadingAnchor), - fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac), - fillView.topAnchor.constraint(equalTo: topAnchor), - fillView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - + + if fillView.superview != self { + addSubview(fillView) + NSLayoutConstraint.activate([ + fillView.leadingAnchor.constraint(equalTo: leadingAnchor), + fillView.topAnchor.constraint(equalTo: topAnchor), + fillView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + fillViewWidthConstraint?.isActive = false + fillViewWidthConstraint = fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac) + fillViewWidthConstraint!.isActive = true + accessibilityLabel! += ", \(percent)" - } - - let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight) - // on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts - // this keeps autolayout from complaining - minHeightConstraint.priority = .required - 1 - - NSLayoutConstraint.activate([ - minHeightConstraint, - - label.topAnchor.constraint(equalTo: topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4), - - percentLabel.topAnchor.constraint(equalTo: topAnchor), - percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor), - percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - ]) - - if let checkbox { - NSLayoutConstraint.activate([ - checkbox.centerYAnchor.constraint(equalTo: centerYAnchor), - checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8), - ]) } else { - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true + percentLabel.isHidden = true + _fillView.valueIfInitialized?.removeFromSuperview() } - - isAccessibilityElement = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") } } diff --git a/Tusker/Views/Poll/PollOptionsView.swift b/Tusker/Views/Poll/PollOptionsView.swift index f3d03a45..ea599b2e 100644 --- a/Tusker/Views/Poll/PollOptionsView.swift +++ b/Tusker/Views/Poll/PollOptionsView.swift @@ -14,7 +14,7 @@ class PollOptionsView: UIControl { var mastodonController: MastodonController! var checkedOptionIndices: [Int] { - options.enumerated().filter { $0.element.checkbox?.isChecked == true }.map(\.offset) + options.enumerated().filter { $0.element.checkboxIfInitialized?.isChecked == true }.map(\.offset) } var checkedOptionsChanged: (() -> Void)? @@ -32,10 +32,15 @@ class PollOptionsView: UIControl { override var isEnabled: Bool { didSet { - options.forEach { $0.checkbox?.readOnly = !isEnabled } + options.forEach { $0.checkboxIfInitialized?.readOnly = !isEnabled } } } + override var accessibilityElements: [Any]? { + get { options } + set {} + } + override init(frame: CGRect) { stack = UIStackView() @@ -61,20 +66,22 @@ class PollOptionsView: UIControl { func updateUI(poll: Poll) { self.poll = poll - options.forEach { $0.removeFromSuperview() } - - options = poll.options.enumerated().map { (index, opt) in - let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController) - if let checkbox = optionView.checkbox { - checkbox.readOnly = !isEnabled - checkbox.isChecked = poll.ownVotes?.contains(index) ?? false - checkbox.voted = poll.voted ?? false + if poll.options.count > options.count { + for _ in 0..<(poll.options.count - options.count) { + let optView = PollOptionView() + options.append(optView) + stack.addArrangedSubview(optView) + } + } else if poll.options.count < options.count { + for _ in 0..<(options.count - poll.options.count) { + options.removeLast().removeFromSuperview() } - stack.addArrangedSubview(optionView) - return optionView } - accessibilityElements = options + for (index, (view, opt)) in zip(options, poll.options).enumerated() { + view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, mastodonController: mastodonController) + view.checkboxIfInitialized?.readOnly = !isEnabled + } } func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { @@ -89,13 +96,13 @@ class PollOptionsView: UIControl { private func selectOption(_ option: PollOptionView) { if poll.multiple { - option.checkbox?.isChecked.toggle() + option.checkboxIfInitialized?.isChecked.toggle() } else { for opt in options { if opt === option { - opt.checkbox?.isChecked = true + opt.checkboxIfInitialized?.isChecked = true } else { - opt.checkbox?.isChecked = false + opt.checkboxIfInitialized?.isChecked = false } } }