skip to Main Content

In my iOS project, I’m working with a UIStackView that contains multiple subviews. I want to dynamically flip or move up and down specific views within the stack view based on a condition. Specifically,

I’m attempting to manipulate the positions of two text fields named FIRST and SECOND. However, I’m encountering an issue where the flipping only works correctly on every second tap or click.

Here is my code for the same

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("---> 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 " + " -> " + 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)
        }
    }
}

Here is the reference to the recording

Thanks in advance.

3

Answers


  1. I could change the code above to

    1. Using CurrentValueSubject<Bool, Never>(false) instead to avoid extra Bool variable
    2. StackView can addArrangedSubview without removing sub views then re-adding again

    The toggle action should be:

    private var toggleSubject = CurrentValueSubject<Bool, Never>(false)
    @objc func toggleButtonAction() {
        toggleSubject.value.toggle()
    }
    
    ...
    //Keep toggleSubject sink as it was
    toggleSubject
        .sink { [weak self] toggle in
            self?.updateUI(toggle: toggle)
        }
        .store(in: &store)
    

    Then remove other stuff in updateUI:

    private func updateUI(toggle: Bool) {
        if toggle {
            self.stackView.addArrangedSubview(topField)
            self.stackView.addArrangedSubview(lineView)
            self.stackView.addArrangedSubview(bottomField)
            self.stackView.addArrangedSubview(toggleButton)
        } else {
            self.stackView.addArrangedSubview(bottomField)
            self.stackView.addArrangedSubview(lineView)
            self.stackView.addArrangedSubview(topField)
            self.stackView.addArrangedSubview(toggleButton)
        }
        UIView.animate(withDuration: 0.25) {
            self.stackView.setNeedsLayout()
            self.stackView.layoutIfNeeded()
        }
    }
    

    Output

    enter image description here


    Update: Since you wanted to keep your current code, I updated the answer to this approach below. The root problem is you remained arrangedSubviews at index 0 and 2 even when these AppTextField swapped each other, the indices need to swap too:

    ...
    let firstIndex = toggle ? 0 : 2
    let secondIndex = toggle ? 2 : 0
    guard let first = arrangedSubviews[firstIndex] as? AppTextField,
          let second = arrangedSubviews[secondIndex] as? AppTextField else { return }
    ...
    
    Login or Signup to reply.
  2. Your problem is in this line

    let changedViews = toggle ? [first, lineView, second, toggleButton] : [second, lineView, first , toggleButton]
    

    It accesses the same item and put it again in same place because of
    the ternary operator , you already swap them by accessing the indices so change it to

    let changedViews = [second, lineView, first , toggleButton]
    
    Login or Signup to reply.
  3. The problem is your logic…

    When you tap the button, you toggle toggle between True and False

    Then you call updateUI(toggle: toggle), at which point this code:

    let changedViews = toggle ? [first, lineView, second, toggleButton] : [second, lineView, first , toggleButton]
    

    Says:

    • If toggle is True
      • don’t change the order
    • else
      • change the order

    Which means your button taps are saying:

    • change the order
    • don’t change the order
    • change the order
    • don’t change the order
    • etc

    So, if your goal is to change the order on every button tap, you don’t need the toggle bool var at all. Change your UI update to this:

    private func updateUI() {
        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 = [second, lineView, first , toggleButton]
        
        guard let firstTextField = changedViews.first as? AppTextField,
              let firstfieldPlaceholder = firstTextField.placeholder else {return}
        
        self.selectedTopFieldLabel.text =  "Selected On Top " + " -> " + firstfieldPlaceholder
        
        changedViews.forEach { view in
            self.stackView.addArrangedSubview(view)
        }
        // Update constraints
        UIView.animate(withDuration: 0.25) {
            self.stackView.setNeedsLayout()
            self.stackView.layoutIfNeeded()
        }
    }
    

    and call it without the bool:

    self.updateUI()
    

    Or, if your goal is to set the order based on a Bool var, you might change your code to this:

    private func updateUI(firstOnTop: Bool) {
        let arrangedSubviews = self.stackView.arrangedSubviews
        
        stackView.arrangedSubviews.forEach({$0.removeFromSuperview()})
        
        let changedViews = firstOnTop ? [self.topField, lineView, self.bottomField, toggleButton] : [self.bottomField, lineView, self.topField , toggleButton]
        
        guard let firstTextField = changedViews.first as? AppTextField,
              let firstfieldPlaceholder = firstTextField.placeholder else {return}
        
        self.selectedTopFieldLabel.text =  "Selected On Top " + " -> " + firstfieldPlaceholder
        
        changedViews.forEach { view in
            self.stackView.addArrangedSubview(view)
        }
        // Update constraints
        UIView.animate(withDuration: 0.25) {
            self.stackView.setNeedsLayout()
            self.stackView.layoutIfNeeded()
        }
    }
    

    and call it like this:

    self.updateUI(firstOnTop: toggle)
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search