From 2bcce13678308ee70b698aed4b8fd4bdb48fc317 Mon Sep 17 00:00:00 2001 From: kate-signal Date: Mon, 3 Nov 2025 15:16:54 -0500 Subject: [PATCH] Fix checkbox & vote count layout for long options --- .../CellViews/CVPollView.swift | 177 +++++++++++++----- 1 file changed, 133 insertions(+), 44 deletions(-) diff --git a/Signal/ConversationView/CellViews/CVPollView.swift b/Signal/ConversationView/CellViews/CVPollView.swift index fac12e198c..e336f42416 100644 --- a/Signal/ConversationView/CellViews/CVPollView.swift +++ b/Signal/ConversationView/CellViews/CVPollView.swift @@ -157,14 +157,19 @@ public class CVPollView: ManualStackView { var optionRowOuterStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .leading, - spacing: 8, + spacing: 4, layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 4)) } - let checkBoxSize = CGSize(square: 22) + let checkBoxSize = CGSize(square: 24) + let checkBoxEndedSize = CGSize(square: 20) let circleSize = CGSize(square: 2) + let progressBarHeight = CGFloat(8) + + let trailingVoteStateSpacing = CGFloat(4) + func buildOptionRowInnerStackConfig(voteLabelWidth: Double) -> CVStackViewConfig { CVStackViewConfig(axis: .horizontal, alignment: .leading, @@ -310,7 +315,7 @@ public class CVPollView: ManualStackView { subviewInfos: subViewInfos ) - let progressBarSize = CGSize(width: maxLabelWidth, height: 8) + let progressBarSize = CGSize(width: maxLabelWidth, height: configurator.progressBarHeight) let optionRowOuterMeasurement = ManualStackView.measure( config: configurator.optionRowOuterStackConfig, measurementBuilder: measurementBuilder, @@ -482,7 +487,7 @@ public class CVPollView: ManualStackView { let pollVoteHandler: (VoteType) -> Void - let checkbox = ManualLayoutView(name: "checkbox") + let checkboxContainer = ManualLayoutView(name: "checkboxContainer") let optionText = CVLabel() let innerStack = ManualStackView(name: "innerStack") let numVotesLabel = CVLabel() @@ -627,13 +632,13 @@ public class CVPollView: ManualStackView { guard type.isPending() else { return } - checkbox.subviews.forEach { $0.removeFromSuperview() } + checkboxContainer.subviews.forEach { $0.removeFromSuperview() } switch type { case .pendingVote, .pendingUnvote: let spinningEllipse = UIImageView(image: UIImage(named: Theme.iconName(.ellipse))) let checkMark = UIImageView(image: UIImage(named: Theme.iconName(.checkmark))) - checkbox.addSubview(spinningEllipse, withLayoutBlock: { [weak self] _ in + checkboxContainer.addSubview(spinningEllipse, withLayoutBlock: { [weak self] _ in guard let self else { return } spinView(view: spinningEllipse) checkMark.frame = CGRect( @@ -644,13 +649,122 @@ public class CVPollView: ManualStackView { ) }) if type == .pendingVote { - checkbox.addSubview(checkMark) + checkboxContainer.addSubview(checkMark) } default: owsFailDebug("Function should only be called for pending states") } } + /// Sets up correct icon & checkbox size based on vote state and whether poll is ended. + private func configureCheckboxContainer( + configurator: Configurator, + pollIsEnded: Bool, + pendingVotesCount: Int + ) { + let circle = UIImageView(image: UIImage(named: Theme.iconName(.circle))) + let checkBoxSize = pollIsEnded ? configurator.checkBoxEndedSize : configurator.checkBoxSize + + checkboxContainer.addSubview(circle, withLayoutBlock: { [weak self] _ in + guard let self = self else { return } + let subviewFrame = CGRect( + x: (checkboxContainer.frame.width - checkBoxSize.width) / 2, + y: (checkboxContainer.frame.height - checkBoxSize.height) / 2, + width: checkBoxSize.width, + height: checkBoxSize.height + ) + Self.setSubviewFrame(subview: circle, frame: subviewFrame) + }) + + switch localUserVoteState { + case .vote: + let checkMarkCircle = UIImageView(image: UIImage(named: Theme.iconName(.checkCircleFill))) + checkboxContainer.addSubview(checkMarkCircle, withLayoutBlock: { [weak self] _ in + guard let self = self else { return } + let subviewFrame = CGRect( + x: (checkboxContainer.frame.width - checkBoxSize.width) / 2, + y: (checkboxContainer.frame.height - checkBoxSize.height) / 2, + width: checkBoxSize.width, + height: checkBoxSize.height + ) + Self.setSubviewFrame(subview: checkMarkCircle, frame: subviewFrame) + }) + checkboxContainer.tintColor = configurator.colorConfigurator.checkboxSelectedColor + case .pendingVote, .pendingUnvote: + // If there's multiple votes pending, don't delay the pending UI because it will pause the + // existing animations and restart them after the delay. + if pendingVotesCount > 1 { + self.displayPendingUI(type: self.localUserVoteState) + checkboxContainer.tintColor = configurator.colorConfigurator.checkboxOutlineColor + break + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pendingDelay) { [weak self] in + guard let self else { return } + self.displayPendingUI(type: self.localUserVoteState) + } + checkboxContainer.tintColor = configurator.colorConfigurator.checkboxOutlineColor + case .unvote: + checkboxContainer.tintColor = configurator.colorConfigurator.checkboxOutlineColor + } + } + + /// Configure correct layout at the trailing edge of the option row. + /// This might be only vote count, or if the poll is ended and the user + /// has voted for an option, a smaller checkbox will appear next to the vote count. + private func configureTrailingVoteState( + configurator: Configurator, + cellMeasurement: CVCellMeasurement, + pollIsEnded: Bool, + localizedVotesString: String, + ) { + let isRTL = CurrentAppContext().isRTL + + let numVotesConfig = CVLabelConfig.unstyledText( + localizedVotesString, + font: UIFont.systemFont(ofSize: 15), + textColor: configurator.colorConfigurator.textColor, + numberOfLines: 0, + lineBreakMode: .byWordWrapping, + textAlignment: .trailing + ) + + let maxOptionWidth = cellMeasurement.cellSize.width + let labelSize = CVText.measureLabel(config: numVotesConfig, maxWidth: maxOptionWidth) + + numVotesConfig.applyForRendering(label: numVotesLabel) + innerStackContainer.addSubview(numVotesLabel, withLayoutBlock: { [weak self] _ in + guard let self = self, let superview = numVotesLabel.superview else { + owsFailDebug("Missing superview.") + return + } + + let yPoint = superview.bounds.maxY - (labelSize.height + 4) + let xPoint = isRTL ? superview.bounds.minX : superview.bounds.maxX - labelSize.width + let subviewFrame = CGRect( + origin: CGPoint(x: xPoint, y: yPoint), + size: labelSize + ) + Self.setSubviewFrame(subview: numVotesLabel, frame: subviewFrame) + }) + + if pollIsEnded && localUserVoteState == .vote { + innerStackContainer.addSubview(checkboxContainer, withLayoutBlock: { [weak self] _ in + guard let self = self, let superview = innerStack.superview else { + owsFailDebug("Missing superview.") + return + } + + let yPoint = superview.bounds.maxY - (configurator.checkBoxEndedSize.height + 4) + let xPoint = isRTL ? superview.bounds.minX + labelSize.width + 4 : superview.bounds.maxX - labelSize.width - configurator.checkBoxSize.width + let subviewFrame = CGRect( + origin: CGPoint(x: xPoint, y: yPoint), + size: configurator.checkBoxEndedSize + ) + Self.setSubviewFrame(subview: checkboxContainer, frame: subviewFrame) + }) + } + } + private func buildOptionRowStack( configurator: Configurator, cellMeasurement: CVCellMeasurement, @@ -661,32 +775,14 @@ public class CVPollView: ManualStackView { pollIsEnded: Bool, pendingVotesCount: Int ) { - checkbox.addSubview(UIImageView(image: UIImage(named: Theme.iconName(.circle)))) + configureCheckboxContainer( + configurator: configurator, + pollIsEnded: pollIsEnded, + pendingVotesCount: pendingVotesCount + ) + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapOption))) - switch localUserVoteState { - case .vote: - let checkMarkCircle = UIImageView(image: UIImage(named: Theme.iconName(.checkCircleFill))) - checkbox.addSubview(checkMarkCircle) - - checkbox.tintColor = configurator.colorConfigurator.checkboxSelectedColor - case .pendingVote, .pendingUnvote: - // If there's multiple votes pending, don't delay the pending UI because it will pause the - // existing animations and restart them after the delay. - if pendingVotesCount > 1 { - self.displayPendingUI(type: self.localUserVoteState) - checkbox.tintColor = configurator.colorConfigurator.checkboxOutlineColor - break - } - DispatchQueue.main.asyncAfter(deadline: .now() + Self.pendingDelay) { [weak self] in - guard let self else { return } - self.displayPendingUI(type: self.localUserVoteState) - } - checkbox.tintColor = configurator.colorConfigurator.checkboxOutlineColor - case .unvote: - checkbox.tintColor = configurator.colorConfigurator.checkboxOutlineColor - } - let optionTextConfig = CVLabelConfig.unstyledText( option, font: UIFont.dynamicTypeBody, @@ -700,11 +796,8 @@ public class CVPollView: ManualStackView { if pollIsEnded { self.isUserInteractionEnabled = false subviews = [optionText] - if localUserVoteState == .vote { - subviews.append(checkbox) - } } else { - subviews = [checkbox, optionText] + subviews = [checkboxContainer, optionText] } let localizedVotesString = localizedNumber(from: votes) @@ -720,16 +813,12 @@ public class CVPollView: ManualStackView { innerStackContainer.addSubviewToFillSuperviewEdges(innerStack) - let numVotesConfig = CVLabelConfig.unstyledText( - localizedVotesString, - font: UIFont.dynamicTypeBody, - textColor: configurator.colorConfigurator.textColor, - numberOfLines: 0, - lineBreakMode: .byWordWrapping, - textAlignment: .trailing + configureTrailingVoteState( + configurator: configurator, + cellMeasurement: cellMeasurement, + pollIsEnded: pollIsEnded, + localizedVotesString: localizedVotesString ) - numVotesConfig.applyForRendering(label: numVotesLabel) - innerStackContainer.addSubviewToFillSuperviewEdges(numVotesLabel) buildProgressBar( votes: votes,