From 1b82b2aedbaaba5af63004353640db2d8084afb9 Mon Sep 17 00:00:00 2001 From: Igor Solomennikov Date: Tue, 23 Sep 2025 12:02:14 -0700 Subject: [PATCH] Clean up and fix layout issues in Edit Avatar screen. (#11990) Stop using OWSTableViewController2. Instead use OWSViewController + UIScrollView. This change is prompted by removing obsolete method of tracking keyboard frame. --- .../Avatars/AvatarEditViewController.swift | 284 +++++++++++------- 1 file changed, 170 insertions(+), 114 deletions(-) diff --git a/Signal/src/ViewControllers/Avatars/AvatarEditViewController.swift b/Signal/src/ViewControllers/Avatars/AvatarEditViewController.swift index e56c9b806d..2ccd1326f5 100644 --- a/Signal/src/ViewControllers/Avatars/AvatarEditViewController.swift +++ b/Signal/src/ViewControllers/Avatars/AvatarEditViewController.swift @@ -6,12 +6,14 @@ import SignalServiceKit import SignalUI -class AvatarEditViewController: OWSTableViewController2 { +class AvatarEditViewController: OWSViewController { private let originalModel: AvatarModel private var model: AvatarModel { didSet { - updateHeaderView() updateNavigation() + if isViewLoaded { + updateHeaderViewState() + } } } private let completion: (AvatarModel) -> Void @@ -22,10 +24,8 @@ class AvatarEditViewController: OWSTableViewController2 { self.originalModel = model self.model = model self.completion = completion + super.init() - createTopHeader() - createBottomFooter() - shouldAvoidKeyboard = true } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { @@ -36,8 +36,37 @@ class AvatarEditViewController: OWSTableViewController2 { super.viewDidLoad() updateNavigation() - updateHeaderView() - updateFooterView() + updateHeaderViewState() + updateFooterViewState() + + let scrollView = UIScrollView() + view.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -8), + ]) + + scrollView.addSubview(topHeader) + topHeader.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topHeader.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + topHeader.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + topHeader.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + topHeader.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + ]) + + scrollView.addSubview(bottomFooterStack) + bottomFooterStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + bottomFooterStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + bottomFooterStack.topAnchor.constraint(equalTo: topHeader.bottomAnchor), + bottomFooterStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + bottomFooterStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + bottomFooterStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + ]) } override func themeDidChange() { @@ -80,10 +109,8 @@ class AvatarEditViewController: OWSTableViewController2 { super.viewWillTransition(to: size, with: coordinator) coordinator.animate { [weak self] _ in - self?.updateHeaderView() self?.updateFooterViewLayout() } completion: { [weak self] _ in - self?.updateHeaderView() self?.updateFooterViewLayout() } } @@ -137,58 +164,67 @@ class AvatarEditViewController: OWSTableViewController2 { switch selectedSegment { case .color: headerTextField.resignFirstResponder() - updateFooterView() + updateFooterViewState() case .text: headerTextField.becomeFirstResponder() - updateFooterView() + updateFooterViewState() } } // MARK: - Header - private let headerImageView = AvatarImageView() - private let headerTextField = UITextField() - private let topHeaderStack = UIStackView() - private func createTopHeader() { - topHeaderStack.isLayoutMarginsRelativeArrangement = true - topHeaderStack.axis = .vertical - topHeaderStack.alignment = .center - - let topSpacer = UIView.vStretchingSpacer() - topHeaderStack.addArrangedSubview(topSpacer) - - headerTextField.adjustsFontSizeToFitWidth = true - headerTextField.textAlignment = .center - headerTextField.delegate = self - headerTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) - headerTextField.returnKeyType = .done - headerTextField.autocorrectionType = .no - headerTextField.spellCheckingType = .no + private lazy var headerImageView: AvatarImageView = { + let imageView = AvatarImageView() + imageView.isUserInteractionEnabled = true + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + private lazy var headerTextField: UITextField = { + let textField = UITextField() + textField.adjustsFontSizeToFitWidth = true + textField.textAlignment = .center + textField.delegate = self + textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + textField.returnKeyType = .done + textField.autocorrectionType = .no + textField.spellCheckingType = .no + textField.isHidden = true + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + private lazy var topHeader: UIView = { headerImageView.addSubview(headerTextField) - headerTextField.autoPinEdgesToSuperviewEdges(with: AvatarBuilder.avatarTextMargins(diameter: Self.headerAvatarSize)) - headerTextField.isHidden = true + let insets = AvatarBuilder.avatarTextMargins(diameter: Self.headerAvatarSize) + NSLayoutConstraint.activate([ + headerImageView.widthAnchor.constraint(equalToConstant: Self.headerAvatarSize), + headerImageView.heightAnchor.constraint(equalToConstant: Self.headerAvatarSize), + headerTextField.leadingAnchor.constraint(equalTo: headerImageView.leadingAnchor, constant: insets.left), + headerTextField.trailingAnchor.constraint(equalTo: headerImageView.trailingAnchor, constant: -insets.right), + headerTextField.topAnchor.constraint(equalTo: headerImageView.topAnchor, constant: insets.top), + headerTextField.bottomAnchor.constraint(equalTo: headerImageView.bottomAnchor, constant: -insets.bottom), + ]) - headerImageView.autoSetDimensions(to: CGSize(square: Self.headerAvatarSize)) - headerImageView.isUserInteractionEnabled = true - topHeaderStack.addArrangedSubview(headerImageView) + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(headerImageView) + NSLayoutConstraint.activate([ + headerImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + headerImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + headerImageView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + headerImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 32), + ]) - let bottomSpacer = UIView.vStretchingSpacer() - topHeaderStack.addArrangedSubview(bottomSpacer) - topSpacer.autoMatch(.height, to: .height, of: bottomSpacer) - topSpacer.autoSetDimension(.height, toSize: 16, relation: .greaterThanOrEqual) - topSpacer.setCompressionResistanceLow() - bottomSpacer.setCompressionResistanceLow() - - topHeader = topHeaderStack - } - - func updateHeaderView() { - topHeaderStack.layoutMargins = cellOuterInsets + return view + }() + private func updateHeaderViewState() { switch model.type { case .icon: headerTextField.isHidden = true - headerImageView.image = SSKEnvironment.shared.avatarBuilderRef.avatarImage(model: model, diameterPoints: UInt(Self.headerAvatarSize)) + headerImageView.image = SSKEnvironment.shared.avatarBuilderRef.avatarImage( + model: model, + diameterPoints: UInt(Self.headerAvatarSize) + ) case .text(let text): headerTextField.isHidden = false headerTextField.textColor = model.theme.foregroundColor @@ -205,29 +241,55 @@ class AvatarEditViewController: OWSTableViewController2 { // MARK: - Footer View - private let bottomFooterStack = UIStackView() - private let segmentedControlContainer = UIView() - private let themePickerContainer = UIView() - private let themeHeaderContainer = UIView() + private lazy var bottomFooterStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + segmentedControlContainer, + themeHeaderContainer, + themePickerContainer, + ]) + stackView.axis = .vertical + stackView.setCustomSpacing(16, after: segmentedControlContainer) + return stackView + }() + private lazy var segmentedControlContainer: UIView = { + let container = UIView() + container.addSubview(segmentedControl) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + segmentedControl.centerXAnchor.constraint(equalTo: container.centerXAnchor), + segmentedControl.leadingAnchor.constraint(equalTo: container.leadingAnchor), + segmentedControl.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + segmentedControl.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + ]) + return container + }() + private lazy var themePickerContainer = UIView() + private lazy var themeHeaderContainer: UIView = { + let label = UILabel() + label.text = OWSLocalizedString( + "AVATAR_EDIT_VIEW_CHOOSE_A_COLOR", + comment: "Text prompting the user to choose a color when editing their avatar" + ) + label.textColor = .Signal.label + label.font = UIFont.dynamicTypeBodyClamped.semibold() - private func createBottomFooter() { - bottomFooterStack.isLayoutMarginsRelativeArrangement = true - bottomFooterStack.axis = .vertical - bottomFooterStack.spacing = 16 - - segmentedControlContainer.addSubview(segmentedControl) - segmentedControl.autoPinEdgesToSuperviewEdges() - bottomFooterStack.addArrangedSubview(segmentedControlContainer) - - bottomFooterStack.addArrangedSubview(themeHeaderContainer) - bottomFooterStack.addArrangedSubview(themePickerContainer) - bottomFooterStack.addArrangedSubview(.vStretchingSpacer()) - - bottomFooter = bottomFooterStack - } - - private func updateFooterView() { + let view = UIView() + view.layoutMargins = UIEdgeInsets( + hMargin: OWSTableViewController2.cellHInnerMargin * 0.5, + vMargin: 8 + ) + view.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), + ]) + return view + }() + private func updateFooterViewState() { if case .text = model.type { segmentedControlContainer.isHiddenInStackView = false themeHeaderContainer.isHiddenInStackView = true @@ -243,25 +305,20 @@ class AvatarEditViewController: OWSTableViewController2 { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + updateFooterViewLayout() + } + override func viewLayoutMarginsDidChange() { + super.viewLayoutMarginsDidChange() updateFooterViewLayout() } private var previousSizeReference: CGFloat? private func updateFooterViewLayout(forceUpdate: Bool = false) { // Update theme options layout only when the view size changes. - guard view.width != previousSizeReference || forceUpdate else { return } - previousSizeReference = view.width + guard view.readableContentGuide.layoutFrame.width != previousSizeReference || forceUpdate else { return } + previousSizeReference = view.readableContentGuide.layoutFrame.width - let hMargin = Self.cellOuterInset(in: view) - bottomFooterStack.layoutMargins = .init( - top: 12, - left: hMargin, - bottom: 10, - right: hMargin - ) - - updateThemeHeaderContainer() updateThemePickerContainer() } @@ -277,27 +334,16 @@ class AvatarEditViewController: OWSTableViewController2 { return optionView } - private func updateThemeHeaderContainer() { - themeHeaderContainer.removeAllSubviews() - - let label = UILabel() - label.text = OWSLocalizedString( - "AVATAR_EDIT_VIEW_CHOOSE_A_COLOR", - comment: "Text prompting the user to choose a color when editing their avatar" - ) - label.textColor = Theme.isDarkThemeEnabled ? .ows_gray05 : .ows_gray90 - label.font = UIFont.dynamicTypeBodyClamped.semibold() - themeHeaderContainer.addSubview(label) - label.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(hMargin: Self.cellHInnerMargin * 0.5, vMargin: 0)) - } - private func updateThemePickerContainer() { themePickerContainer.removeAllSubviews() - themePickerContainer.layoutMargins = UIEdgeInsets(hMargin: Self.cellHInnerMargin, vMargin: Self.cellVInnerMargin) - themePickerContainer.backgroundColor = cellBackgroundColor - themePickerContainer.layer.cornerRadius = Self.cellRounding + themePickerContainer.layoutMargins = UIEdgeInsets( + hMargin: OWSTableViewController2.cellHInnerMargin, + vMargin: OWSTableViewController2.cellVInnerMargin + ) + themePickerContainer.backgroundColor = Theme.tableCell2PresentedBackgroundColor + themePickerContainer.layer.cornerRadius = OWSTableViewController2.cellRounding - let rowWidth = max(0, view.width - (view.safeAreaInsets.totalWidth + cellOuterInsets.totalWidth + Self.cellHInnerMargin * 2)) + let rowWidth = max(0, view.readableContentGuide.layoutFrame.width - OWSTableViewController2.cellHInnerMargin * 2) let themeSpacing: CGFloat = 16 let minThemeSize: CGFloat = min(66, (rowWidth - (themeSpacing * 3)) / 4) let themesPerRow = max(1, Int(floor(rowWidth + themeSpacing) / (minThemeSize + themeSpacing))) @@ -338,7 +384,7 @@ extension AvatarEditViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() - updateFooterView() + updateFooterViewState() return false } @@ -350,12 +396,12 @@ extension AvatarEditViewController: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { segmentedControl.selectedSegmentIndex = Segments.text.rawValue - updateFooterView() + updateFooterViewState() } func textFieldDidEndEditing(_ textField: UITextField) { segmentedControl.selectedSegmentIndex = Segments.color.rawValue - updateFooterView() + updateFooterViewState() } } @@ -373,7 +419,6 @@ private protocol OptionViewDelegate: AnyObject { private class OptionView: UIView { private weak var delegate: OptionViewDelegate? private let colorView = UIView() - private var colorViewInsetConstraints: [NSLayoutConstraint]? var isSelected = false { didSet { @@ -387,7 +432,16 @@ private class OptionView: UIView { super.init(frame: .zero) + layoutMargins = .zero + addSubview(colorView) + colorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + colorView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + colorView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + colorView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + colorView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) updateSelectionState() addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) @@ -406,24 +460,21 @@ private class OptionView: UIView { @objc private func handleTap() { - guard let theme = theme else { + guard let theme else { return owsFailDebug("Unexpectedly missing theme in OptionView") } - if !isSelected { - isSelected = true - delegate?.didSelectOptionView(self, theme: theme) - } + guard !isSelected else { return } + + isSelected = true + delegate?.didSelectOptionView(self, theme: theme) } - func updateSelectionState() { - colorViewInsetConstraints?.forEach { $0.isActive = false } - colorViewInsetConstraints = colorView.autoPinEdgesToSuperviewEdges( - with: isSelected ? UIEdgeInsets(margin: 4) : .zero - ) + private func updateSelectionState() { + layoutMargins = isSelected ? UIEdgeInsets(margin: 4) : .zero if isSelected { - layer.borderColor = Theme.primaryTextColor.cgColor + layer.borderColor = UIColor.Signal.label.cgColor layer.borderWidth = 2.5 } else { layer.borderColor = nil @@ -431,11 +482,16 @@ private class OptionView: UIView { } } - func updateTheme() { + private func updateTheme() { colorView.backgroundColor = theme?.backgroundColor } - private var theme: AvatarTheme? { didSet { updateTheme() }} + private var theme: AvatarTheme? { + didSet { + updateTheme() + } + } + func configure(theme: AvatarTheme, isSelected: Bool) { self.theme = theme self.isSelected = isSelected