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.
This commit is contained in:
Igor Solomennikov 2025-09-23 12:02:14 -07:00 committed by GitHub
parent b0366644ab
commit 1b82b2aedb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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