Form-filling is ubiquitous on the front end, but on iOS, it’s not as easy, mainly because we need UITableView and UICollectionView to display item by item data.

But UITableView’s UICollectionView API is not like writing an HTML, you can just write whatever you need, you can just see from the HTML what a form looks like. But on iOS, we write a bunch of logic to return cells. When updating the UI, it is not as easy as updating the HTML, but simply updating the CORRESPONDING DOM. However, due to the reuse of cells, we cannot find the corresponding “DOM” well. Complete forms also involve form validation issues, which further increases the difficulty of submitting forms on iOS.

In this article we will focus on improving the current form-filling experience and code optimizations. We will also focus on the composition of forms:

  • Improve the filling experience

  • Complex form filling

  • Form validation

  • Form combination

Example code address: github.com/DianQK/gitc…

Improve the filling experience

Here’s a very simple form:

Select multiple users and submit.

Experience the interaction of TIM (QQ office version produced by Tencent) as a selective user, and you can feel that the feedback from clicking on Cell is not smooth enough. Here’s something to think about:

Do we need to refresh the view? Is. Can we reload the Cell? Is. Do we have to reload the Cell? No, we can just take the Cell and update the UI on the Cell.

Well, let’s figure out how to do it. We’re going to do two things:

  • If the current Cell is on the screen, update the Cell to the style we expect.

  • Update the corresponding Model.

To do this, we simply add bindings to the Model, and changes in Model values will directly update the contents on the Cell.

We can add an isSelected property to each Model creation corresponding to the Cell as follows:

    struct Item {
        let name: String
        let isSelected: Variable<Bool>
    }
Copy the code

Next we just need to bind the isSelected state to the Cell’s view change:

Driver.just((1... 9).map { Item(name: "\($0)", isSelected: Variable<Bool>(false)) }) .drive(tableView.rx.items(cellIdentifier: "Cell", cellType: ReactiveTableViewCell.self)) { row, item, cell in cell.textLabel? .text = item.name item.isSelected.asDriver() .map { isSelected -> UITableViewCellAccessoryType in if isSelected { return  UITableViewCellAccessoryType.checkmark } else { return UITableViewCellAccessoryType.none } } .drive(cell.rx.accessoryType) .disposed(by: cell.reuseDisposeBag) } .disposed(by: disposeBag)Copy the code

We bind the isSelected property of each Model to the accessoryType property of the Cell using the map method.

With reuseDisposeBag, we don’t have to worry about Cell reuse:

    open class ReactiveTableViewCell: UITableViewCell {

        public private(set) var reuseDisposeBag = DisposeBag()

        open override func prepareForReuse() {
            super.prepareForReuse()
            reuseDisposeBag = DisposeBag()
        }

    }
Copy the code

We have already released the previous DisposeBag in preparation for reuse.

In order to update the data, we only need to directly update the corresponding isSelected, without considering manually updating the Cell status.

For example, we can switch the selection state of the corresponding Item when clicking:

tableView.rx.modelSelected(Item.self).asDriver() .map { $0.isSelected } .drive(onNext: { isSelected in isSelected.value = ! isSelected.value }) .disposed(by: disposeBag)Copy the code

To update the state on a given Cell, we just need to create the corresponding Variable.

Complex form filling improvements

Forms are dynamic in two scenarios:

  • Display different items to be filled in on the same page in different scenarios.

  • The current fill in page displays some option boxes.

These are all things that static UITableView can’t do.

The first scenario is easy to understand, for example, you are not a WeChat super member, when hair circle of friends, there is no check for private functions, the check items also do not show on the UI, and when you prepaid phone after a micro COINS, again hair circle of friends, has the private option, we need to check for private Cell is added to the page.

When you fill out an item in a form, you may need to select some data from a list, or you may need to select some data on the current page. There are four common practices:

  • You can directly modify UITextField and UISwitch.

  • Push a new page, and after the page selection is complete, the data is called back to the previous page.

  • An Action Sheet pops up on the current page to select the appropriate item.

  • Insert an option Cell in the UITableView/UICollectionView and select the corresponding item in this Cell.

Let’s take the TIM creation schedule as an example. In this example, there are various ways to fill in the content, refresh the view, and I do most of the logic (except for selecting participants, which is left up to the reader to follow the way the logic is handled in the example).

let name: Variable<String> let startTime: Variable<Date> let endTime: Variable<Date> let location: Variable<String> let participants: Variable<[Member]> let remind: Variable<Remind? > let isBellEnabled: Variable<Bool> let note: Variable<String>Copy the code

This TIM creation schedule requires multiple pieces of data, name, start time, end time, location, participants, reminders, bells, and notes. To do this, I create all the data I need for the form:

class ScheduleForm { let name: Variable<String> let startTime: Variable<Date> let endTime: Variable<Date> let location: Variable<String> let participants: Variable<[Member]> let remind: Variable<Remind? > let isBellEnabled: Variable<Bool> let note: Variable<String> }Copy the code

All we need to do next is split the ScheduleForm into the corresponding entries on the Cell.

You will need to experience some of TIM’s interactions first to understand the logic of the process that follows.

First, we need to determine which situations need to update the UITableView and which can update the UITableViewCell directly.

Participants need to update the UITableView, or Reload item. Because participants are a group of data, we may display all participants in the view, and the height of the Cell needs to be updated accordingly.

UIDatePicker also needs to update the UITableView, the Insert Item.

Display reminders and remarks and the fill in items also need to update the UITableView, the Insert Item.

Note also need to update the UITableView, adjust the height of the Cell for multiple lines.

For other items, such as update schedule subject, start time, end time, etc., we can choose to directly update the Cell, as shown below

Observable .combineLatest(scheduleForm.participants.asObservable(), selectingTime.asObservable(), isNeedRemind.asObservable(), scheduleForm.note.asObservable()) { (participants: [Member], selectingTime: Variable<Date>?, isNeedRemind: Bool, note: Return [baseSection, participantsSection, remindSection, remindSection, noteSection] } .bind(to: tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag)Copy the code

Methods such as reloadData or Reload Item are called to update the view when the participant is involved, when the update is selected, when a reminder is required, and when a comment is made.

Now look at all the items in our form:

Enum NewScheduleItem: Equatable, IdentifiableType {case name(Variable<String>) // Case time(start: Variable<Date>, end: Variable<Date>) // Start time and end time case selectTime(Variable<Date>) // Time selection case location(Variable<String>) // location case Participants ([Member]) // addRemind and note case remind(Variable< remind? >) case bell(isBellEnabled: Variable<Bool>) case note(String) // Remarks}Copy the code

Here each case corresponds to a form item, but cells can be reused, such as name and location.

RxDataSources diff data twice to compare which cells need to be added, deleted, and updated. Correctly implementing the protocol IdentifiableType is critical.

Here, each case will only appear once in the data, and a different identity will be given to each case. We simply convert the name of the case to a String:

    var identity: String {
        switch self {
        case .name:
            return "name"
        case .selectTime:
            return "selectTime"
        case .time:
            return "time"
        case .location:
            return "location"
        case .participants:
            return "participants"
        case .addRemind:
            return "addRemind"
        case .remind:
            return "remind"
        case .bell:
            return "bell"
        case .note:
            return "note"
        }
    }
Copy the code

To determine whether the same item needs to be updated, implement Equatable:

    static func ==(lhs: NewScheduleItem, rhs: NewScheduleItem) -> Bool {
        switch (lhs, rhs) {
        case (.name, .name):
            return true
        case (.time, .time):
            return true
        case (let .selectTime(lTime), let .selectTime(rTime)):
            return lTime === rTime
        case (.location, .location):
            return true
        case (let .participants(lMembers), let .participants(rMembers)):
            return lMembers == rMembers
        case (.addRemind, .addRemind):
            return true
        case (.bell, .bell):
            return true
        case (let .note(lNote), let .note(rNote)):
            return lNote == rNote
        default:
            return false
        }
    }
Copy the code

The important thing to note here is that case selectTime is not comparing whether they are the same, but whether they are the same object.

Let’s first complete a relatively simple logic, click “Fill in the note”, “remind”, and display the fill in items of “remind” and “note” :

    tableView.rx.modelSelected(NewScheduleItem.self)
                .flatMap { (newScheduleItem) -> Observable<Bool> in
                    switch newScheduleItem {
                    case .addRemind:
                        return Observable.just(true)
                    default:
                        return Observable.empty()
                    }
                }
                .bind(to: isNeedRemind)
                .disposed(by: disposeBag)
Copy the code

When you click on addRemind, set true to isNeedRemind. We have done isNeedRemind to update the logic of UITableView. Here we set the value of isNeedRemind to true to complete the logic of expanding and filling in reminders and remarks.

Combination of the form

Combined forms are when one form needs content from another, and we need to key the two forms together.

For example, this list page for selecting reminders can be interpreted as a form, but it has very little content to fill in, only one item for selecting reminders in advance.

This selection reminder lead time is not just a small form, but also a form for creating a schedule.

This little form the corresponding logic in ScheduleRemindViewController, in order to use ScheduleRemindViewController amicably, we need to update the two parameters:

Var currentStartDate: Date = Date() var remind: Variable< remind? >! // Select a reminder, nil means select noneCopy the code

We Segue to this scene (equivalent to implementing Push a new page logic directly in the observer), set the start time in the passed argument, and pass the selection to remind the instance:

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard let identifier = segue.identifier else {
            super.prepare(for: segue, sender: sender)
            return
        }
        switch (segue.destination, identifier, sender) {
        case let (viewController as ScheduleRemindViewController, "changeRemind", (sender) as (selectedRemind: Variable<Remind?>, currentStartTime: Date)):
            viewController.currentStartDate = sender.currentStartTime
            viewController.remind = sender.selectedRemind
        default:
            break
        }
    }
Copy the code

Next, we only need to deal with the logic in ScheduleRemindViewController.

Fill in the remark also can choose the same way we deal with, in addition we still have a way of handling, ScheduleNoteViewController encapsulated into observables, update of text content through the observables transfer:

    tableView.rx.modelSelected(NewScheduleItem.self)
        .flatMap { (newScheduleItem) -> Observable<String> in
            switch newScheduleItem {
            case let .note(note):
                return Observable.just(note)
            default:
                return Observable.empty()
            }
        }
        .flatMap { [weak self] (defaultText) -> Observable<String> in
            return ScheduleNoteViewController.rx.createScheduleNote(defaultText: defaultText, previousViewController: self)
                .flatMap { $0.rx.done }
                .take(1)
        }
        .bind(to: scheduleForm.note)
        .disposed(by: disposeBag)
Copy the code

Code ScheduleNoteViewController. Rx. CreateScheduleNote (defaultText: defaultText previousViewController: Self).flatmap {$0.rx.done}. Take (1) successfully passes the change logic into an Observable, which is much clearer and easier to understand than segues.

We can understand the logic of a complete feature directly from the last few lines of code:

Click Cell. If “Modify Remarks” is clicked, the page for modifying Remarks will be entered. When the modification is complete, obtain the modified content (only once) and set the content to ScheduleForm. note.

The problem of transferring values between the two viewControllers is solved by sharing variables or installing an Observable.

Form validation

In order to ensure that the user fills in the form correctly, we need to restrict the user’s input, such as preventing certain buttons from being clicked and error messages appearing after clicking.

When setting a reminder, we should not set the reminder earlier than the current time.

.flatMap { [weak self] (selectedRemind, currentRemind) -> Observable<Remind? > in guard let `self` = self else { return Observable.empty() } return Observable<Remind? >.create({ (observer) -> Disposable in if selectedRemind == currentRemind { observer.onCompleted() } else if let selectedRemind = selectedRemind, selectedRemind.changedTime(for: Self. CurrentStartDate) < Date () {observer. OnError (CustomMessageError. Message (" please set later than current time remind event "))} else { observer.onNext(selectedRemind) observer.onCompleted() } return Disposables.create() }) .catchErrorShowMessageWithCompleted() }Copy the code

If the selected time is earlier than the current time, an error message is displayed indicating that the notification event is later than the current time.

You may be wondering why I’m throwing an error instead of just popping up an error message and returning an Observable.empty(). Since throwing an error is a better way to stop the current flow of events, we can also do more based on the error, such as replacing the lead time with a more appropriate reminder.

Selecting the start time and end time is a little more difficult. When switching the start time and end time, the same time selection Cell is used. Here we update DatePicker’s Variable

using a Reload item.

conclusion

Working with forms on iOS is complicated, and we need to take care of reusing and updating views. This makes completing a form a lot easier with responsive support. In this example, the content that each view needs to display is bound by Observable, so that we don’t have to worry about view update any more and focus on data update and data association processing.

Don’t forget to try to fill out the participants with the information in this article.