A Form Built From Data¶
A form whose questions are data. You add, edit and reorder them, and the preview and the answers table follow.
The example for this page builds as tutorial_16_form_from_data.

In the todo list every row has the same shape. Here the data decides the shape. A question's type decides which input the preview shows, another question's answer decides whether it shows at all, and the questions are the answer table's columns. The full source is examples/tutorials/16_form_from_data/main.cpp.
The window has four parts: the list of questions on the left, the selected question's editor next to it, the form as someone filling it in sees it, and the answers sent so far at the bottom.
The Model¶
enum class Kind
{
Text,
Number,
Choice,
YesNo,
};
constexpr std::array Kinds{Kind::Text, Kind::Number, Kind::Choice, Kind::YesNo};
const char* kind_name(Kind kind)
{
switch (kind)
{
case Kind::Text:
return "Text";
case Kind::Number:
return "Number";
case Kind::Choice:
return "Choice";
case Kind::YesNo:
return "Yes or no";
}
return "";
}
struct Choice
{
explicit Choice(std::string t) : text{std::move(t)}
{
}
ui::Property<std::string> text;
};
// One question: what the editor changes, and the answer the preview collects. Both sides bind to
// these Properties, so neither copies anything to the other.
struct Question
{
Question(std::uint64_t question_id, Kind question_kind, std::string question) :
id(question_id),
kind{question_kind},
label{std::move(question)}
{
}
std::uint64_t id;
ui::Property<Kind> kind;
ui::Property<std::string> label;
ui::Property<bool> required{false};
ui::ObservableVector<Choice> choices;
ui::Property<std::uint64_t> after{0}; // shown once this question is answered; 0 for always
// The answer, one Property per kind of input, and what the form makes of it.
ui::Property<std::string> text;
ui::Property<std::optional<double>> number;
ui::Property<std::optional<std::size_t>> picked;
ui::Property<std::optional<bool>> yes{false};
ui::Property<bool> shown{true};
ui::Property<std::string> error;
std::vector<ui::Subscription> watches;
};
A question is a struct of Properties in a ui::ObservableVector, like a task in the todo list. It holds what the editor changes (its type, its question, its choices and the question it follows) and the answer the preview collects. The editor and the preview bind to the same Properties, so neither copies anything to the other: typing a question in the editor changes the preview's label as you type.
The answer has one Property per kind of input. A question whose type changes keeps what was entered, and gets it back if the type changes back.
The Form¶
// A column of the answers table: which question, and its label when the columns were made.
struct Column
{
std::uint64_t id = 0;
std::string label;
friend bool operator==(const Column&, const Column&) = default;
};
struct Response
{
std::map<std::uint64_t, std::string> answers;
friend bool operator==(const Response&, const Response&) = default;
};
struct Form
{
ui::ObservableVector<Question> questions;
ui::Property<std::optional<std::uint64_t>> selected;
// What a "show after" picker chooses from: 0 for "always", then every question, by id.
ui::Property<std::vector<std::uint64_t>> after_ids{std::vector<std::uint64_t>{0}};
ui::Property<std::vector<std::string>> after_labels{std::vector<std::string>{"Always"}};
ui::Property<std::vector<Column>> columns;
ui::ObservableVector<Response> responses;
ui::Property<std::string> status;
std::uint64_t next_id = 1;
ui::Subscription watch;
Form()
{
watch = questions.observe([this](const ui::CollectionChange&) { refresh(); });
}
Question& add(Kind kind, std::string label)
{
Question& q = questions.emplace_back(next_id++, kind, std::move(label));
// The lists that name the questions follow their labels and kinds; whether each question
// shows follows every answer it may depend on.
q.watches.push_back(ui::observe([this](const auto&...) { refresh(); }, q.label, q.kind));
q.watches.push_back(
ui::observe([this](const auto&...) { update_shown(); }, q.after, q.text, q.number, q.picked, q.yes));
refresh();
return q;
}
void remove(std::uint64_t id)
{
if (selected.get() == id)
{
selected = std::nullopt;
}
questions.erase_if([id](const Question& q) { return q.id == id; });
}
Question* find(std::uint64_t id)
{
for (Question& q : questions)
{
if (q.id == id)
{
return &q;
}
}
return nullptr;
}
static std::string answer(const Question& q)
{
switch (q.kind.get())
{
case Kind::Text:
return q.text.get();
case Kind::Number:
return q.number.get() ? std::format("{}", *q.number.get()) : std::string{};
case Kind::Choice:
{
const auto i = q.picked.get();
return i && *i < q.choices.size() ? q.choices[*i].text.get() : std::string{};
}
case Kind::YesNo:
return q.yes.get().value_or(false) ? "Yes" : "";
}
return {};
}
void refresh()
{
std::vector<std::uint64_t> ids{0};
std::vector<std::string> labels{"Always"};
std::vector<Column> cols;
for (const Question& q : questions)
{
ids.push_back(q.id);
labels.push_back("After: " + q.label.get());
cols.push_back({.id = q.id, .label = q.label.get()});
}
after_ids = std::move(ids);
after_labels = std::move(labels);
columns = std::move(cols);
update_shown();
}
// A question shows when it follows nothing, or when the one it follows shows and is answered.
// It can only follow a question above it, so one pass from the top settles them all.
void update_shown()
{
std::map<std::uint64_t, bool> answered;
for (Question& q : questions)
{
const auto target = answered.find(q.after.get());
q.shown = q.after.get() == 0 || (target != answered.end() && target->second);
answered[q.id] = q.shown.get() && !answer(q).empty();
}
}
void send()
{
Response response;
bool complete = true;
for (Question& q : questions)
{
const std::string a = answer(q);
q.error = q.shown.get() && q.required.get() && a.empty() ? "This question needs an answer." : "";
complete = complete && q.error.get().empty();
if (q.shown.get())
{
response.answers[q.id] = a;
}
}
if (!complete)
{
status = "Some answers are missing.";
return;
}
responses.push_back(std::move(response));
for (Question& q : questions)
{
q.text = "";
q.number = std::nullopt;
q.picked = std::nullopt;
q.yes = false;
}
status = std::format("Sent. {} so far.", responses.size());
}
};
refresh() makes the lists that name the questions: the choices of the "Show" pickers and the answer table's columns. Each question subscribes to what those lists depend on with one ui::observe() over several Properties. The subscriptions live in the question, so a question removed from the collection takes them along.
update_shown() decides which questions show. A question can follow one above it, and shows once that one shows and has an answer. One pass from the top settles them all.
The Editor¶
ui::Spec choice_row(Question& q, Choice& choice)
{
return ui::Grid{
.columns = {ui::GridLength::fraction(), ui::GridLength::auto_()},
.column_spacing = 4,
.content =
{
ui::Cell{.column = 0,
.content = ui::TextBox{.text = ui::bind(choice.text), .accessibility = {.name = "Choice"}}},
ui::Cell{.column = 1,
.content = ui::Button{.text = "Remove",
.on_click = [&q, &choice] { (void)q.choices.remove(choice); }}},
},
};
}
ui::Spec choices_editor(Question& q)
{
ui::Spec rows = ui::ForEach<Choice>{
.items = q.choices,
.layout = ui::StackLayout{.spacing = 4},
.item = [&q](Choice& choice) -> ui::Spec { return choice_row(q, choice); },
};
return ui::VerticalStack{
.spacing = 4,
.visible = ui::bind(q.kind, [](Kind k) { return k == Kind::Choice; }),
.content =
{
ui::Text{.text = "Choices"},
std::move(rows),
ui::Button{.text = "Add a choice", .on_click = [&q] { q.choices.emplace_back("New choice"); }},
},
};
}
ui::Spec question_editor(Form& form, Question& q)
{
std::vector<std::string> kinds;
for (const Kind k : Kinds)
{
kinds.emplace_back(kind_name(k));
}
return ui::VerticalStack{
.spacing = 12,
.content =
{
// A Kind shown as an index: to() turns the model's value into the field's, from()
// turns what the user picks back.
ui::ComboBox{.header = "Type",
.items = std::move(kinds),
.selected_index = ui::bind(
q.kind,
[](Kind k) { return std::optional<std::size_t>{static_cast<std::size_t>(k)}; },
[](const std::optional<std::size_t>& i) { return i ? Kinds[*i] : Kind::Text; })},
ui::TextBox{.header = "Question", .text = ui::bind(q.label)},
ui::CheckBox{.text = "Needs an answer", .checked = ui::bind(q.required)},
choices_editor(q),
// The question's id, picked from the ids as they are now: reordering the questions
// moves the selection with them.
ui::ComboBox{.header = "Show",
.items = ui::bind(form.after_labels),
.selected_index = ui::bind_choice(q.after, form.after_ids)},
ui::Button{.text = "Delete the question", .on_click = [&form, id = q.id] { form.remove(id); }},
},
};
}
// Rebuilt for each selected question. It builds from the id it's given, not from form.selected or
// anything else that may be halfway through changing: a Match runs inside the write that changed
// its value.
ui::Spec editor(Form& form)
{
return ui::Match<std::optional<std::uint64_t>>{
.value = ui::bind(form.selected),
.content = [&form](const std::optional<std::uint64_t>& id) -> ui::Spec
{
Question* q = id ? form.find(*id) : nullptr;
if (q == nullptr)
{
return ui::Text{.text = "Pick a question to change it."};
}
return question_editor(form, *q);
},
};
}
The type picker is a ComboBox, whose .selected_index is an index, while the model holds a Kind. ui::bind(q.kind, to, from) is two-way through a pair of conversions: to turns the property's value into the field's, and from turns what the user picks back into a Kind. Use it wherever a control's type isn't the model's.
The "Show" picker stores the id of the question to follow, picked from form.after_ids, a list that changes as questions come and go. ui::bind_choice(q.after, form.after_ids) shows the position of the id in the list as it is now, so dragging the questions into another order moves the selection with them.
The editor is a ui::Match on the selected question's id, rebuilt each time another question is selected and at no other time. Typing in it doesn't rebuild it. The content looks up its question from the id it was given. That matters because a Match's content runs inside the write that changed its value, while other state may still be halfway through changing, so the value is the one thing it can rely on.
None of the fields needs a handler, since each two-way binding writes the user's change into the question. A handler, where you add one, reports the changes the user made. A write to the bound Property doesn't run it, so filling the editor from the model never looks like an edit (see Events and Commands).
The Preview¶
// The question names the input: it's the input's header, which screen readers read as its name.
ui::Binding<std::string> header(Question& q)
{
return ui::bind([](const std::string& label, bool required) { return required ? label + " *" : label; },
q.label,
q.required);
}
ui::Spec input(Question& q, Kind kind)
{
switch (kind)
{
case Kind::Text:
return ui::TextBox{.header = header(q), .text = ui::bind(q.text)};
case Kind::Number:
return ui::NumberBox{.header = header(q), .value = ui::bind(q.number)};
case Kind::Choice:
// The choices' texts, one Property in each item, as the list the drop-down shows.
return ui::ComboBox{.header = header(q),
.items = ui::bind_each(q.choices, &Choice::text),
.selected_index = ui::bind(q.picked)};
case Kind::YesNo:
return ui::CheckBox{.text = header(q), .checked = ui::bind(q.yes)};
}
return {};
}
ui::Spec preview_question(Question& q)
{
// A new type builds a new input; the answer lives in the question, so nothing is lost.
return ui::Match<Kind>{
.value = ui::bind(q.kind),
.visible = ui::bind(q.shown),
.content = [&q](const Kind& kind) -> ui::Spec
{
return ui::VerticalStack{
.spacing = 4,
.content =
{
input(q, kind),
ui::Text{.text = ui::bind(q.error),
.foreground = ui::theme_colors::Critical,
.visible = ui::bind(q.error, [](const std::string& e) { return !e.empty(); })},
},
};
},
};
}
ui::Spec preview(Form& form)
{
ui::Spec questions = ui::ForEach<Question>{
.items = form.questions,
.layout = ui::StackLayout{.spacing = 16},
.item = [](Question& q) -> ui::Spec { return preview_question(q); },
};
ui::Spec send = ui::HorizontalStack{
.spacing = 12,
.content =
{
ui::Button{.text = "Send", .variant = ui::ButtonVariant::Accent, .on_click = [&form] { form.send(); }},
ui::Text{.text = ui::bind(form.status), .vertical_alignment = ui::Alignment::Center},
},
};
return ui::Scroll{
.content = ui::VerticalStack{.spacing = 20, .padding = 4, .content = {std::move(questions), std::move(send)}}};
}
Each question is a ui::Match on its type. Changing the type in the editor rebuilds that one input, and the answer survives because it lives in the question. .visible = ui::bind(q.shown) hides a question that doesn't apply without destroying its input.
The question goes into the input's .header. An input uses its header as its name for screen readers, so the name follows the question as it changes.
The choices are an ObservableVector<Choice> whose texts are Properties, and a ComboBox wants a std::vector<std::string>. ui::bind_each(q.choices, &Choice::text) is that list. It follows the collection and each choice's text, so adding a choice or retyping one shows at once.
The Answers Table¶
ui::Spec answers_table(Form& form, const std::vector<Column>& columns)
{
std::vector<ui::TableColumn<Response>> table_columns;
for (const Column& c : columns)
{
table_columns.push_back(
{.header = c.label,
.width = ui::AutoSize,
.text = [id = c.id](const Response& r)
{
const auto a = r.answers.find(id);
return a == r.answers.end() ? std::string{} : a->second;
}});
}
return ui::TableView<Response>{
.items = form.responses,
.columns = std::move(table_columns),
.empty_content = ui::Text{.text = "Answers you send show here."},
};
}
// A table's columns are fixed when it's built, so the table is rebuilt when they change. The Match
// keys on the columns themselves: an edit that leaves them as they were rebuilds nothing.
ui::Spec answers(Form& form)
{
return ui::Match<std::vector<Column>>{
.value = ui::bind(form.columns),
.content = [&form](const std::vector<Column>& columns) -> ui::Spec { return answers_table(form, columns); },
};
}
A ui::TableView's columns are given once, when it is built, so a table whose columns come from data is rebuilt when they change. The Match keys on the columns themselves, a std::vector<Column> that compares equal while no question was added, removed, moved or renamed. Answering, sending or changing a question's type rebuilds nothing. Key a Match on what its content is built from, and it rebuilds exactly when the content would differ.
Putting It Together¶
int main()
{
Form form;
sample(form);
ui::Window window{{.title = "A form built from data", .size = {1200, 780}}};
ui::Spec panes = ui::Grid{
.columns = {ui::GridLength::dip(260), ui::GridLength::dip(300), ui::GridLength::fraction()},
.column_spacing = 24,
.content =
{
ui::Cell{.column = 0, .content = outline(form)},
ui::Cell{.column = 1, .content = ui::Scroll{.content = editor(form)}},
ui::Cell{.column = 2, .content = preview(form)},
},
};
window.set_content(ui::Grid{
.rows = {ui::GridLength::fraction(), ui::GridLength::dip(200)},
.row_spacing = 16,
.padding = 16,
.content =
{
ui::Cell{.row = 0, .content = std::move(panes)},
ui::Cell{.row = 1, .content = answers(form)},
},
});
return ui::run(window);
}
The Tools on This Page¶
| You need | Use |
|---|---|
| A control whose type isn't the model's | ui::bind(prop, to, from) |
| An index into a list that changes | ui::bind_choice(prop, list) |
| One Property of each item, as a list | ui::bind_each(items, &T::member) |
| A part whose shape comes from data | ui::Match keyed on that data, built from its value |
| A part that comes and goes with an answer | .visible = ui::bind(...) |