You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于条件切换UIStackView视图位置时的异常问题求助

动态切换UIStackView中子视图位置异常问题解决

问题描述

在iOS项目中,使用包含多个子视图的UIStackView,希望点击切换按钮时动态交换名为FIRST和SECOND的两个文本框的上下位置,但出现每两次点击才会正确切换一次的异常。

问题演示:
问题演示录屏

原代码

import UIKit
import Combine
import SnapKit

class ViewController: UIViewController {
    
    private var toggleSubject = PassthroughSubject<Bool, Never>()
    private var toggle = false
    private var store = Set<AnyCancellable>()
    
    private lazy var topField: AppTextField = {
        let view = AppTextField(title: "First")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var bottomField: AppTextField = {
        let view = AppTextField(title: "Second")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var lineView: UIImageView = {
        let view = UIImageView(frame: .zero)
        view.backgroundColor = .red
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var stackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [topField, lineView, bottomField, toggleButton])
        view.axis  = .vertical
        view.spacing = 4.0
        view.distribution = .fill
        view.alignment = .fill
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var toggleButton: UIButton = {
        let view = UIButton(frame: .zero)
        view.setTitle("Toggle", for: .normal)
        view.setTitleColor(.white, for: .normal)
        view.setTitleColor(.systemYellow, for: .highlighted)
        view.backgroundColor = .darkGray
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    
    private lazy var selectedTopFieldLabel: UILabel = {
        let view = UILabel(frame: .zero)
        view.textAlignment = .center
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(stackView)
        self.view.addSubview(selectedTopFieldLabel)
        toggleSubject.send(toggle)
        
        stackView.snp.makeConstraints { make in
            make.leading.trailing.centerY.equalToSuperview().inset(16)
        }
        
        lineView.snp.makeConstraints { make in
            make.height.equalTo(0.5)
        }
        
        selectedTopFieldLabel.snp.makeConstraints { make in
            make.top.equalToSuperview().offset(200)
        }
        
        
        toggleButton.addTarget(self, action: #selector(toggleButtonAction), for: .touchUpInside)
        
        toggleSubject.sink { toggle in
            print("---&gt; Changed Toggle",toggle)
            self.updateUI(toggle: toggle)
            UIView.animate(withDuration: 0.25) {
                self.stackView.layoutIfNeeded()
            }
        }.store(in: &store)
        self.updateUI(toggle: toggle)
    }
    
    @objc func toggleButtonAction() {
        toggle.toggle()
        toggleSubject.send(toggle)
    }
    
    private func updateUI(toggle: Bool) {
        let arrangedSubviews = self.stackView.arrangedSubviews
        
        guard var first = arrangedSubviews[0] as? AppTextField,
              var second = arrangedSubviews[2] as? AppTextField else {return}
        stackView.arrangedSubviews.forEach({$0.removeFromSuperview()})
        let changedViews = toggle ? [first, lineView, second, toggleButton] : [second, lineView, first , toggleButton]

        guard let firstTextField = changedViews.first as? AppTextField,
              let firstfieldPlaceholder = firstTextField.placeholder else {return}

        self.selectedTopFieldLabel.text =  "Selected On Top " + " -&gt; " + firstfieldPlaceholder

        changedViews.forEach { view in
            self.stackView.addArrangedSubview(view)
        }
        // Update constraints
        UIView.animate(withDuration: 0.25) {
            self.stackView.setNeedsLayout()
            self.stackView.layoutIfNeeded()
        }
    }
}

class AppTextField: UIView {
    
    private lazy var inputField: UITextField = {
        let textField = UITextField(frame: .zero)
        textField.backgroundColor = .lightGray.withAlphaComponent(0.15)
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    
    var title: String? {
        get { return inputField.placeholder }
        set { inputField.placeholder = newValue }
    }
    
    var placeholder: String? {
        get { return inputField.placeholder }
        set { inputField.placeholder = newValue }
    }
    
    var text: String? {
        get { return inputField.text }
        set { inputField.text = newValue }
    }
    
    init(title: String) {
        super.init(frame: .zero)
        self.title = title
        setupViews()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(inputField)
        inputField.snp.makeConstraints { make in
            make.edges.equalToSuperview()
            make.height.equalTo(48)
        }
    }
}

问题原因

  1. 依赖索引获取视图错误:updateUI方法中硬取arrangedSubviews[0]和arrangedSubviews[2]作为两个文本框,但第一次切换后,stackView的子视图顺序已改变,第二次取到的是交换后的视图,导致逻辑反转。
  2. 重复操作与动画冲突:viewDidLoad和sink回调中都调用了updateUI,且两处都做了动画,导致布局更新混乱。
  3. 不必要的视图移除重加:每次切换都移除所有子视图再重新添加,不仅冗余,还可能引发布局状态异常。

解决方案

修改核心逻辑

  1. 直接使用类中定义的topField和bottomField属性(固定引用,不受顺序影响)来构建新的子视图数组。
  2. 简化布局更新方式,直接重新设置stackView.arrangedSubviews,无需移除重加。
  3. 统一动画调用,避免重复。

修改后的代码

import UIKit
import Combine
import SnapKit

class ViewController: UIViewController {
    
    private var toggleSubject = PassthroughSubject<Bool, Never>()
    private var toggle = false
    private var store = Set<AnyCancellable>()
    
    private lazy var topField: AppTextField = {
        let view = AppTextField(title: "First")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var bottomField: AppTextField = {
        let view = AppTextField(title: "Second")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var lineView: UIImageView = {
        let view = UIImageView(frame: .zero)
        view.backgroundColor = .red
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var stackView: UIStackView = {
        let view = UIStackView(arrangedSubviews: [topField, lineView, bottomField, toggleButton])
        view.axis  = .vertical
        view.spacing = 4.0
        view.distribution = .fill
        view.alignment = .fill
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private lazy var toggleButton: UIButton = {
        let view = UIButton(frame: .zero)
        view.setTitle("Toggle", for: .normal)
        view.setTitleColor(.white, for: .normal)
        view.setTitleColor(.systemYellow, for: .highlighted)
        view.backgroundColor = .darkGray
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    
    private lazy var selectedTopFieldLabel: UILabel = {
        let view = UILabel(frame: .zero)
        view.textAlignment = .center
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(stackView)
        self.view.addSubview(selectedTopFieldLabel)
        
        stackView.snp.makeConstraints { make in
            make.leading.trailing.centerY.equalToSuperview().inset(16)
        }
        
        lineView.snp.makeConstraints { make in
            make.height.equalTo(0.5)
        }
        
        selectedTopFieldLabel.snp.makeConstraints { make in
            make.top.equalToSuperview().offset(200)
        }
        
        toggleButton.addTarget(self, action: #selector(toggleButtonAction), for: .touchUpInside)
        
        toggleSubject.sink { [weak self] toggle in
            guard let self = self else { return }
            print("---&gt; Changed Toggle",toggle)
            self.updateUI(toggle: toggle)
            // 统一在这里做动画
            UIView.animate(withDuration: 0.25) {
                self.stackView.layoutIfNeeded()
            }
        }.store(in: &store)
        
        // 初始化UI
        self.updateUI(toggle: toggle)
    }
    
    @objc func toggleButtonAction() {
        toggle.toggle()
        toggleSubject.send(toggle)
    }
    
    private func updateUI(toggle: Bool) {
        // 直接使用固定的topField和bottomField,不受顺序影响
        let newArrangedSubviews: [UIView]
        if toggle {
            newArrangedSubviews = [bottomField, lineView, topField, toggleButton]
            selectedTopFieldLabel.text = "Selected On Top -&gt; Second"
        } else {
            newArrangedSubviews = [topField, lineView, bottomField, toggleButton]
            selectedTopFieldLabel.text = "Selected On Top -&gt; First"
        }
        
        // 直接重新设置arrangedSubviews,无需移除重加
        stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
        newArrangedSubviews.forEach { stackView.addArrangedSubview($0) }
        
        stackView.setNeedsLayout()
    }
}

class AppTextField: UIView {
    
    private lazy var inputField: UITextField = {
        let textField = UITextField(frame: .zero)
        textField.backgroundColor = .lightGray.withAlphaComponent(0.15)
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    
    var title: String? {
        get { return inputField.placeholder }
        set { inputField.placeholder = newValue }
    }
    
    var placeholder: String? {
        get { return inputField.placeholder }
        set { inputField.placeholder = newValue }
    }
    
    var text: String? {
        get { return inputField.text }
        set { inputField.text = newValue }
    }
    
    init(title: String) {
        super.init(frame: .zero)
        self.title = title
        setupViews()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(inputField)
        inputField.snp.makeConstraints { make in
            make.edges.equalToSuperview()
            make.height.equalTo(48)
        }
    }
}

关键优化点

  • 不再依赖arrangedSubviews的索引获取文本框,改用类属性的固定引用,确保逻辑正确性。
  • 移除重复的动画调用,统一在sink回调中执行布局动画。
  • 简化视图顺序更新逻辑,直接构建新的子视图数组并重新设置。

内容的提问来源于stack exchange,提问作者DRN

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 03:05:01