Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/CellViews/CVPollView.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient
public import SignalServiceKit
public import SignalUI

public protocol CVPollVoteDelegate: AnyObject {
    func didTapVoteOnPoll(poll: OWSPoll, optionIndex: UInt32, isUnvote: Bool)
}

public class CVPollView: ManualStackView {
    struct State: Equatable {
        let poll: OWSPoll
        let isIncoming: Bool
        let conversationStyle: ConversationStyle
        let localAci: Aci
    }

    public weak var pollVoteDelegate: CVPollVoteDelegate?

    private let subtitleStack = ManualStackView(name: "subtitleStack")
    private let questionTextLabel = CVLabel()
    private let pollLabel = CVLabel()
    private let chooseLabel = CVLabel()

    private static let measurementKey_outerStack = "CVPollView.measurementKey_outerStack"
    private static let measurementKey_subtitleStack = "CVPollView.measurementKey_subtitleStack"
    private static let measurementKey_optionStack = "CVPollView.measurementKey_optionStack"
    fileprivate static let measurementKey_optionRowOuterStack = "CVPollView.measurementKey_optionOuterRowStack"
    fileprivate static let measurementKey_optionRowInnerStack = "CVPollView.measurementKey_optionInnerRowStack"

    /*
     OuterStack
     [
        [ Question Text ]
        [ Poll, Select One/multiple ] <-- SubtitleStack
        OptionStack
        [
            OptionRowOuterStack
            [
                [checkbox, option text] <-- OptionRowInnerStack
                progressBar
            ]

            OptionRowOuterStack
            [
                [checkbox, option text] <-- OptionRowInnerStack
                progressBar
            ]

            ...
        ]
     ]
     */
    fileprivate struct Configurator {
        fileprivate struct ColorConfigurator {
            let textColor: UIColor
            let subtitleColor: UIColor
            let checkboxOutlineColor: UIColor
            let voteProgressBackgroundColor: UIColor
            let voteProgressForegroundColor: UIColor
            let checkboxSelectedColor: UIColor

            init(state: CVPollView.State) {
                self.textColor = state.conversationStyle.bubbleTextColor(isIncoming: state.isIncoming)
                self.subtitleColor = state.conversationStyle.bubbleSecondaryTextColor(isIncoming: state.isIncoming)

                if state.isIncoming {
                    self.checkboxOutlineColor = UIColor.Signal.tertiaryLabel
                    self.voteProgressBackgroundColor = UIColor.Signal.label.withAlphaComponent(0.1)
                    self.voteProgressForegroundColor = UIColor.Signal.ultramarine
                    self.checkboxSelectedColor = UIColor.Signal.ultramarine
                } else {
                    self.checkboxOutlineColor = textColor.withAlphaComponent(0.8)
                    self.voteProgressBackgroundColor = textColor.withAlphaComponent(0.4)
                    self.voteProgressForegroundColor = textColor
                    self.checkboxSelectedColor = textColor
                }
            }
        }

        let poll: OWSPoll
        var outerStackConfig: CVStackViewConfig
        let colorConfigurator: ColorConfigurator

        init(state: CVPollView.State) {
            self.poll = state.poll
            self.outerStackConfig = CVStackViewConfig(
                axis: .vertical,
                alignment: .leading,
                spacing: 2,
                layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: state.isIncoming ? 0 : 8),
            )
            self.colorConfigurator = ColorConfigurator(state: state)
        }

        var questionTextLabelConfig: CVLabelConfig {
            return CVLabelConfig.unstyledText(
                poll.question,
                font: UIFont.dynamicTypeHeadline,
                textColor: colorConfigurator.textColor,
                numberOfLines: 0,
                lineBreakMode: .byWordWrapping,
            )
        }

        var subtitleStackConfig: CVStackViewConfig {
            CVStackViewConfig(
                axis: .horizontal,
                alignment: .leading,
                spacing: 4,
                layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 0),
            )
        }

        var pollSubtitleTextLabelConfig: CVLabelConfig {
            return CVLabelConfig.unstyledText(
                OWSLocalizedString("POLL_LABEL", comment: "Label specifying the message type as a poll"),
                font: UIFont.dynamicTypeFootnote,
                textColor: colorConfigurator.textColor.withAlphaComponent(0.8),
                numberOfLines: 0,
                lineBreakMode: .byWordWrapping,
            )
        }

        var chooseSubtitleTextLabelConfig: CVLabelConfig {
            var selectLabel: String
            if poll.isEnded {
                selectLabel = OWSLocalizedString("POLL_FINAL_RESULTS_LABEL", comment: "Label specifying the poll is finished and these are the final results")
            } else {
                selectLabel = poll.allowsMultiSelect ? OWSLocalizedString(
                    "POLL_SELECT_LABEL_MULTIPLE",
                    comment: "Label specifying the user can select more than one option",
                ) : OWSLocalizedString(
                    "POLL_SELECT_LABEL_SINGULAR",
                    comment: "Label specifying the user can select one option",
                )
            }

            return CVLabelConfig.unstyledText(
                selectLabel,
                font: UIFont.dynamicTypeFootnote,
                textColor: colorConfigurator.textColor.withAlphaComponent(0.8),
                numberOfLines: 0,
                lineBreakMode: .byWordWrapping,
            )
        }

        var optionStackConfig: CVStackViewConfig {
            CVStackViewConfig(
                axis: .vertical,
                alignment: .leading,
                spacing: 8,
                layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 16),
            )
        }

        var optionRowOuterStackConfig: CVStackViewConfig {
            CVStackViewConfig(
                axis: .vertical,
                alignment: .leading,
                spacing: 4,
                layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 4),
            )
        }

        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,
                spacing: 8,
                layoutMargins: UIEdgeInsets(top: 2, leading: 0, bottom: 2, trailing: voteLabelWidth),
            )
        }
    }

    static func buildState(
        poll: OWSPoll,
        isIncoming: Bool,
        conversationStyle: ConversationStyle,
        localAci: Aci,
    ) -> State {
        return State(
            poll: poll,
            isIncoming: isIncoming,
            conversationStyle: conversationStyle,
            localAci: localAci,
        )
    }

    private static func localizedNumber(from votes: Int) -> String {
        let formatter: NumberFormatter = {
            let f = NumberFormatter()
            f.numberStyle = .decimal
            return f
        }()

        return formatter.string(from: NSNumber(value: votes))!
    }

    private static func voteLabelWidthWithPadding(localizedVotes: String) -> Double {
        let attributes = [NSAttributedString.Key.font: UIFont.dynamicTypeBody]
        let textSize = localizedVotes.size(withAttributes: attributes)
        return textSize.width + 4
    }

    static func measure(
        maxWidth: CGFloat,
        measurementBuilder: CVCellMeasurement.Builder,
        state: CVPollView.State,
    ) -> CGSize {
        owsAssertDebug(maxWidth > 0)

        let poll = state.poll
        let configurator = Configurator(state: state)
        let maxLabelWidth = (maxWidth - (configurator.outerStackConfig.layoutMargins.totalWidth))
        var outerStackSubviewInfos = [ManualStackSubviewInfo]()

        // MARK: - Question

        let questionTextLabelConfig = configurator.questionTextLabelConfig
        let questionSize = CVText.measureLabel(
            config: questionTextLabelConfig,
            maxWidth: maxLabelWidth,
        )

        outerStackSubviewInfos.append(questionSize.asManualSubviewInfo)

        // MARK: - Subtitle

        var subtitleStackSubviews = [ManualStackSubviewInfo]()

        let pollSubtitleLabelConfig = configurator.pollSubtitleTextLabelConfig
        let pollSubtitleSize = CVText.measureLabel(
            config: pollSubtitleLabelConfig,
            maxWidth: maxLabelWidth,
        )
        subtitleStackSubviews.append(pollSubtitleSize.asManualSubviewInfo)

        // Small bullet
        subtitleStackSubviews.append(configurator.circleSize.asManualSubviewInfo(hasFixedSize: true))

        let chooseSubtitleLabelConfig = configurator.chooseSubtitleTextLabelConfig
        let chooseSubtitleSize = CVText.measureLabel(
            config: chooseSubtitleLabelConfig,
            maxWidth: maxLabelWidth,
        )
        subtitleStackSubviews.append(chooseSubtitleSize.asManualSubviewInfo)

        let subtitleStackMeasurement = ManualStackView.measure(
            config: configurator.subtitleStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: measurementKey_subtitleStack,
            subviewInfos: subtitleStackSubviews,
        )

        outerStackSubviewInfos.append(subtitleStackMeasurement.measuredSize.asManualSubviewInfo)

        // MARK: - Options

        var optionStackRows = [ManualStackSubviewInfo]()
        for option in poll.sortedOptions() {
            let optionTextConfig = CVLabelConfig.unstyledText(
                option.text,
                font: UIFont.dynamicTypeBody,
                textColor: configurator.colorConfigurator.textColor,
                numberOfLines: 0,
                lineBreakMode: .byWordWrapping,
            )

            let hasLocalUserVoted = option.localUserHasVoted(localAci: state.localAci)

            // When the poll is ended, the checkbox should be removed except for options
            // the local user voted for. Those checkboxes should be shifted right.
            // In order to make sure they don't overlap with vote count, we need to measure
            // the vote count width and update the option row stack config trailing
            // spacing accordingly.
            let checkboxSize = poll.isEnded && !hasLocalUserVoted ? 0 : configurator.checkBoxSize.width + 8

            let localizedVotesString = localizedNumber(from: option.acis.count)
            let voteLabelWidth = voteLabelWidthWithPadding(localizedVotes: localizedVotesString)
            let innerStackConfig = configurator.buildOptionRowInnerStackConfig(voteLabelWidth: voteLabelWidth)

            let maxOptionLabelWidth = (maxLabelWidth - (
                innerStackConfig.layoutMargins.trailing +
                    checkboxSize +
                    innerStackConfig.spacing
            ))

            let optionLabelTextSize = CVText.measureLabel(
                config: optionTextConfig,
                maxWidth: maxOptionLabelWidth,
            )

            // Even though the text may not take up the whole width, we should use the max
            // row size because the number of votes will be displayed on the far side.
            let optionRowSize = CGSize(
                width: maxOptionLabelWidth,
                height: optionLabelTextSize.height,
            )

            var subViewInfos: [ManualStackSubviewInfo] = []
            if poll.isEnded {
                subViewInfos = [optionRowSize.asManualSubviewInfo]
                if hasLocalUserVoted {
                    subViewInfos.append(configurator.checkBoxSize.asManualSubviewInfo(hasFixedSize: true))
                }
            } else {
                subViewInfos = [configurator.checkBoxSize.asManualSubviewInfo(hasFixedSize: true), optionRowSize.asManualSubviewInfo]
            }

            let optionRowInnerMeasurement = ManualStackView.measure(
                config: innerStackConfig,
                measurementBuilder: measurementBuilder,
                measurementKey: measurementKey_optionRowInnerStack + String(option.optionIndex),
                subviewInfos: subViewInfos,
            )

            let progressBarSize = CGSize(width: maxLabelWidth, height: configurator.progressBarHeight)
            let optionRowOuterMeasurement = ManualStackView.measure(
                config: configurator.optionRowOuterStackConfig,
                measurementBuilder: measurementBuilder,
                measurementKey: measurementKey_optionRowOuterStack + String(option.optionIndex),
                subviewInfos: [optionRowInnerMeasurement.measuredSize.asManualSubviewInfo, progressBarSize.asManualSubviewInfo],
            )

            optionStackRows.append(optionRowOuterMeasurement.measuredSize.asManualSubviewInfo)
        }

        let optionStackMeasurement = ManualStackView.measure(
            config: configurator.optionStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_optionStack,
            subviewInfos: optionStackRows,
        )
        outerStackSubviewInfos.append(optionStackMeasurement.measuredSize.asManualSubviewInfo)

        // MARK: - Outer Stack

        let outerStackMeasurement = ManualStackView.measure(
            config: configurator.outerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_outerStack,
            subviewInfos: outerStackSubviewInfos,
        )

        return outerStackMeasurement.measuredSize
    }

    private func buildSubtitleStack(configurator: Configurator, cellMeasurement: CVCellMeasurement) {
        let pollLabelConfig = configurator.pollSubtitleTextLabelConfig
        pollLabelConfig.applyForRendering(label: pollLabel)

        let chooseLabelConfig = configurator.chooseSubtitleTextLabelConfig
        chooseLabelConfig.applyForRendering(label: chooseLabel)

        let circleView = UIView()
        circleView.backgroundColor = configurator.colorConfigurator.subtitleColor
        circleView.layer.cornerRadius = configurator.circleSize.width / 2

        let circleContainer = ManualLayoutView(name: "circleContainer")
        circleContainer.addSubview(circleView, withLayoutBlock: { [weak self] _ in
            guard let self else {
                return
            }

            let subviewFrame = CGRect(
                origin: CGPoint(x: 0, y: chooseLabel.bounds.midY),
                size: configurator.circleSize,
            )
            Self.setSubviewFrame(subview: circleView, frame: subviewFrame)
        })

        subtitleStack.configure(
            config: configurator.subtitleStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_subtitleStack,
            subviews: [pollLabel, circleContainer, chooseLabel],
        )
    }

    private func localUserVoteState(
        localAci: Aci,
        option: OWSPoll.OWSPollOption,
    ) -> VoteState {
        if option.localUserHasVoted(localAci: localAci), option.latestPendingState == nil {
            return .vote
        } else if let pendingState = option.latestPendingState {
            switch pendingState {
            case .pendingUnvote:
                return .pendingUnvote
            case .pendingVote:
                return .pendingVote
            }
        }
        return .unvote
    }

    func configureForRendering(
        state: CVPollView.State,
        previousPollState: CVPollView.State?,
        cellMeasurement: CVCellMeasurement,
        componentDelegate: CVComponentDelegate,
        accessibilitySummary: String,
    ) {
        let poll = state.poll

        let configurator = Configurator(state: state)
        var outerStackSubViews = [UIView]()

        let questionTextLabelConfig = configurator.questionTextLabelConfig
        questionTextLabelConfig.applyForRendering(label: questionTextLabel)
        outerStackSubViews.append(questionTextLabel)

        // Accessibility
        questionTextLabel.isAccessibilityElement = true
        questionTextLabel.accessibilityLabel = accessibilitySummary

        buildSubtitleStack(configurator: configurator, cellMeasurement: cellMeasurement)
        outerStackSubViews.append(subtitleStack)

        var optionSubviews = [UIView]()
        for option in poll.sortedOptions() {
            let row = PollOptionView(
                configurator: configurator,
                cellMeasurement: cellMeasurement,
                pollOption: option,
                prevOption: previousPollState?.poll.optionForIndex(optionIndex: option.optionIndex),
                totalVoters: poll.totalVoters(),
                prevTotalVoters: previousPollState?.poll.totalVoters(),
                localUserVoteState: localUserVoteState(localAci: state.localAci, option: option),
                pollIsEnded: poll.isEnded,
                pendingVotesCount: poll.pendingVotesCount(),
                pollVoteHandler: { [weak self, weak componentDelegate] voteType in
                    self?.handleVote(
                        for: option,
                        on: poll,
                        voteType: voteType,
                        delegate: componentDelegate,
                    )
                },
            )
            optionSubviews.append(row)
        }

        let optionsStack = ManualStackView(name: "optionsStack")
        optionsStack.configure(
            config: configurator.optionStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_optionStack,
            subviews: optionSubviews,
        )
        outerStackSubViews.append(optionsStack)

        self.configure(
            config: configurator.outerStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_outerStack,
            subviews: outerStackSubViews,
        )
    }

    private func handleVote(
        for option: OWSPoll.OWSPollOption,
        on poll: OWSPoll,
        voteType: VoteType,
        delegate: CVPollVoteDelegate?,
    ) {
        delegate?.didTapVoteOnPoll(
            poll: poll,
            optionIndex: option.optionIndex,
            isUnvote: voteType == .unvote,
        )
    }

    override public func reset() {
        super.reset()

        questionTextLabel.text = nil

        pollLabel.text = nil
        chooseLabel.text = nil
        subtitleStack.reset()
    }

    // MARK: - PollOptionView

    /// Class representing an option row which displays and updates selected state

    enum VoteType {
        case unvote
        case vote
    }

    class PollOptionView: ManualStackView {
        typealias OWSPollOption = OWSPoll.OWSPollOption

        static let pendingDelay: TimeInterval = 0.3

        let pollVoteHandler: (VoteType) -> Void

        let checkboxContainer = ManualLayoutView(name: "checkboxContainer")
        let optionText = CVLabel()
        let innerStack = ManualStackView(name: "innerStack")
        let numVotesLabel = CVLabel()
        let innerStackContainer = ManualLayoutView(name: "innerStackContainer")
        let progressFill = UIView()
        let progressBarBackground = UIView()
        let progressBarContainer = ManualLayoutView(name: "progressBarContainer")
        let generator = UINotificationFeedbackGenerator()
        var didAnimate = false

        var localUserVoteState: VoteState = .unvote

        fileprivate init(
            configurator: Configurator,
            cellMeasurement: CVCellMeasurement,
            pollOption: OWSPollOption,
            prevOption: OWSPollOption?,
            totalVoters: Int,
            prevTotalVoters: Int?,
            localUserVoteState: VoteState,
            pollIsEnded: Bool,
            pendingVotesCount: Int,
            pollVoteHandler: @escaping (VoteType) -> Void,
        ) {
            self.pollVoteHandler = pollVoteHandler
            self.localUserVoteState = localUserVoteState
            generator.prepare()

            super.init(name: "PollOptionView")

            // Accessibility
            let localizedVotesString = String.localizedStringWithFormat(
                OWSLocalizedString(
                    "POLL_VOTE_COUNT",
                    tableName: "PluralAware",
                    comment: "Count indicating number of votes for this option. Embeds {{number of votes}}",
                ),
                pollOption.acis.count,
            )

            isAccessibilityElement = true
            switch localUserVoteState {
            case .vote:
                accessibilityTraits.insert(.selected)
                accessibilityLabel = "\(pollOption.text). \(localizedVotesString)"
            case .unvote:
                accessibilityTraits.remove(.selected)
                accessibilityLabel = "\(pollOption.text). \(localizedVotesString)"
            case .pendingVote, .pendingUnvote:
                accessibilityTraits.remove(.selected)
                accessibilityLabel = OWSLocalizedString("POLL_ACCESSIBILITY_LABEL_OPTION_PENDING", comment: "Accessibility label for a vote option that is not selected by the user.") + ".\(pollOption.text). \(localizedVotesString)"
            }

            if !pollIsEnded {
                accessibilityTraits.insert(.button)
            } else {
                accessibilityTraits.insert(.staticText)
            }

            buildOptionRowStack(
                configurator: configurator,
                cellMeasurement: cellMeasurement,
                option: pollOption.text,
                index: pollOption.optionIndex,
                votes: pollOption.acis.count,
                prevVotes: prevOption?.acis.count,
                totalVoters: totalVoters,
                prevTotalVoters: prevTotalVoters,
                pollIsEnded: pollIsEnded,
                pendingVotesCount: pendingVotesCount,
            )
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        @objc
        private func didTapOption() {
            var attemptedVoteType: VoteType
            switch localUserVoteState {
            case .unvote, .pendingUnvote:
                attemptedVoteType = .vote
            case .vote, .pendingVote:
                attemptedVoteType = .unvote
            }
            pollVoteHandler(attemptedVoteType)
            generator.notificationOccurred(.success)
        }

        private func buildProgressBar(
            votes: Int,
            prevVotes: Int?,
            totalVoters: Int,
            prevTotalVoters: Int?,
            pollIsEnded: Bool,
            foregroundColor: UIColor,
            backgroundColor: UIColor,
            checkboxWidthWithSpacing: CGFloat,
        ) {
            let isRTL = CurrentAppContext().isRTL

            progressFill.backgroundColor = foregroundColor
            progressFill.layer.cornerRadius = 5
            progressBarBackground.backgroundColor = backgroundColor
            progressBarBackground.layer.cornerRadius = 5

            progressBarContainer.addSubview(progressBarBackground, withLayoutBlock: { [weak self] _ in
                guard let self, let superview = progressBarBackground.superview else {
                    owsFailDebug("Missing superview.")
                    return
                }

                // The progress bar should start under the text, not the checkbox, so we need to shift it
                // over the amount of the checkbox width (plus spacing), and remove that offset from the total size.
                // If the poll is ended, there's no shifting.
                let checkboxOffset = pollIsEnded ? 0 : checkboxWidthWithSpacing
                let adjustedContainerSize = CGSize(
                    width: superview.bounds.width - checkboxOffset,
                    height: superview.bounds.height,
                )

                // If RTL, the checkbox is on the right, so we don't want to shift the
                // progress bar origin. It will still have the same adjustedContainerSize.
                let originX = isRTL ? 0 : superview.bounds.origin.x + checkboxOffset
                let subviewFrame = CGRect(
                    origin: CGPoint(x: originX, y: superview.bounds.origin.y),
                    size: adjustedContainerSize,
                )

                Self.setSubviewFrame(subview: progressBarBackground, frame: subviewFrame)
            })

            progressBarContainer.addSubview(progressFill, withLayoutBlock: { [weak self] _ in
                guard let self, let superview = progressFill.superview else {
                    owsFailDebug("Missing superview.")
                    return
                }

                var percent = 0.0 as Float
                if totalVoters > 0 {
                    percent = Float(votes) / Float(totalVoters)
                }
                var prevPercent = 0.0 as Float
                if let prevVotes, let prevTotalVoters, prevTotalVoters > 0 {
                    prevPercent = Float(prevVotes) / Float(prevTotalVoters)
                }

                // The progress bar should start under the text, not the checkbox, so we need to shift it
                // over the amount of the checkbox width (plus spacing), and remove that offset from the total size.
                // If the poll is ended, there's no shifting and adjustedContainerWidth equals the width.
                let checkboxOffset = pollIsEnded ? 0 : checkboxWidthWithSpacing
                let adjustedContainerWidth = superview.bounds.width - checkboxOffset
                let numVotesBarFill = CGFloat(percent) * adjustedContainerWidth
                let prevNumVotesBarFill = CGFloat(prevPercent) * adjustedContainerWidth

                // Origin references the left. If RTL, we want the origin to be its "finished" point, which is
                // the total container size minus the fill size.
                var originX: CGFloat = 0
                if isRTL {
                    originX = superview.bounds.origin.x + (adjustedContainerWidth - numVotesBarFill)
                } else {
                    originX = superview.bounds.origin.x + checkboxOffset
                }

                var subviewFrame = CGRect(
                    origin: CGPoint(x: originX, y: superview.bounds.origin.y),
                    size: CGSize(width: progressFill.frame.width, height: superview.bounds.height),
                )

                // CVPollView is discarded and re-rendered everytime the vote state changes,
                // so we only ever want to animate once (when appearing) for each view.
                // But, layoutSubviews() is called multiple times when creating the view
                // which can cause glitchiness in the animations, or the wrong bar fill.
                if prevVotes != nil {
                    // If this view already animated/animating, that means this is a
                    // repeat call to layoutSubviews() and we don't want to change the
                    // width - the animation will set it correctly once it completes.
                    if !didAnimate {
                        subviewFrame.width = prevNumVotesBarFill
                    }
                } else {
                    // Don't animate if there's no previous state, just set to the final width.
                    subviewFrame.width = numVotesBarFill
                    Self.setSubviewFrame(subview: progressFill, frame: subviewFrame)
                    return
                }

                Self.setSubviewFrame(subview: progressFill, frame: subviewFrame)
                if !didAnimate {
                    didAnimate = true

                    DispatchQueue.main.async { [weak self] in
                        // Start animation at previous state's fill, and finish at new state's fill.
                        self?.progressFill.frame.width = prevNumVotesBarFill
                        if prevNumVotesBarFill != numVotesBarFill {
                            UIView.animate(
                                withDuration: 0.25,
                                delay: 0.0,
                                usingSpringWithDamping: 0.7,
                                initialSpringVelocity: 0.0,
                                options: [],
                                animations: { [weak self] in
                                    self?.progressFill.frame.width = numVotesBarFill
                                },
                                completion: nil,
                            )
                        }
                    }
                }
            })
        }

        private func spinView(view: UIView) {
            let animation = CABasicAnimation(keyPath: "transform.rotation.z")
            animation.toValue = NSNumber(value: Double.pi * 2)
            animation.duration = TimeInterval.second
            animation.isCumulative = true
            animation.repeatCount = .greatestFiniteMagnitude
            view.layer.add(animation, forKey: "spin")
        }

        private func displayPendingUI(type: VoteState) {
            guard type.isPending() else {
                return
            }
            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)))
                checkboxContainer.addSubview(spinningEllipse, withLayoutBlock: { [weak self] _ in
                    guard let self else { return }
                    spinView(view: spinningEllipse)
                    checkMark.frame = CGRect(
                        x: (spinningEllipse.frame.width - 15) / 2,
                        y: (spinningEllipse.frame.height - 15) / 2,
                        width: 15,
                        height: 15,
                    )
                })
                if type == .pendingVote {
                    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 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 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, 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, 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,
            option: String,
            index: UInt32,
            votes: Int,
            prevVotes: Int?,
            totalVoters: Int,
            prevTotalVoters: Int?,
            pollIsEnded: Bool,
            pendingVotesCount: Int,
        ) {
            configureCheckboxContainer(
                configurator: configurator,
                pollIsEnded: pollIsEnded,
                pendingVotesCount: pendingVotesCount,
            )

            addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapOption)))

            let optionTextConfig = CVLabelConfig.unstyledText(
                option,
                font: UIFont.dynamicTypeBody,
                textColor: configurator.colorConfigurator.textColor,
                numberOfLines: 0,
                lineBreakMode: .byWordWrapping,
            )
            optionTextConfig.applyForRendering(label: optionText)

            var subviews: [UIView] = []
            if pollIsEnded {
                self.isUserInteractionEnabled = false
                subviews = [optionText]
            } else {
                subviews = [checkboxContainer, optionText]
            }

            let localizedVotesString = localizedNumber(from: votes)
            let voteLabelWidth = voteLabelWidthWithPadding(localizedVotes: localizedVotesString)
            let innerStackConfig = configurator.buildOptionRowInnerStackConfig(voteLabelWidth: voteLabelWidth)

            innerStack.configure(
                config: innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: measurementKey_optionRowInnerStack + String(index),
                subviews: subviews,
            )

            innerStackContainer.addSubviewToFillSuperviewEdges(innerStack)

            configureTrailingVoteState(
                configurator: configurator,
                cellMeasurement: cellMeasurement,
                pollIsEnded: pollIsEnded,
                localizedVotesString: localizedVotesString,
            )

            buildProgressBar(
                votes: votes,
                prevVotes: prevVotes,
                totalVoters: totalVoters,
                prevTotalVoters: prevTotalVoters,
                pollIsEnded: pollIsEnded,
                foregroundColor: configurator.colorConfigurator.voteProgressForegroundColor,
                backgroundColor: configurator.colorConfigurator.voteProgressBackgroundColor,
                checkboxWidthWithSpacing: configurator.checkBoxSize.width + innerStackConfig.spacing,
            )

            configure(
                config: configurator.optionRowOuterStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: measurementKey_optionRowOuterStack + String(index),
                subviews: [innerStackContainer, progressBarContainer],
            )
        }
    }
}