Recently, I tried out the mental health assessment in the Apple Health app in iOS 17:
I liked that the app would automatically scroll to the next question when I complete a question, and the UI itself was pleasing.
Hence, I have been trying to replicate this using SwiftUI, and made something quite close:
However, there is a small difference that has been annoying me, the incomplete horizontal bar between the question and the options (circled in red). In the original app, the divider seems to stretch all the way to the extreme left. I really want that.
I am using Section
for QuestionView
and List
for QuizView
, and would greatly appreciate it if you could help me extend the bar using SwiftUI (and also provide feedback for my replica). Here is my source code:
//
// QuizView.swift
// Swole
//
// Created by Mashrafi Rahman on 18/8/23.
//
import SwiftUI
import IdentifiedCollections
class QuizViewModel: ObservableObject, Codable {
/// Question in the quiz.
@Published var questions: IdentifiedArrayOf<Question>
private enum CodingKeys: CodingKey { case questions }
init(questions: IdentifiedArrayOf<Question> = .init()) {
self.questions = questions
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.questions = try container.decode(
IdentifiedArrayOf<Question>.self,
forKey: .questions
)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.questions, forKey: .questions)
}
/// Whether any changes have been made.
var is_changed: Bool { self.questions.contains { $0.is_answered } }
/// Whether the quiz is ready to be submitted.
var is_ready: Bool { self.questions.allSatisfy { $0.is_answered } }
/// Index of the first question that has not been filled.
var next: UUID? { self.questions.first { !$0.is_answered }?.id }
/// Returns data for submission in the form of an array containing arrays of selected elements.
func submit() -> [[Int]]? {
return is_ready ? questions.map { $0.selected } : nil
}
}
struct QuizView: View {
@StateObject var quiz: QuizViewModel = .init()
var body: some View {
ScrollViewReader { proxy in
List {
ForEach($quiz.questions) { $q in
let i: Int = quiz.questions.index(id: q.id)! + 1
let l: Int = quiz.questions.count
QuestionView(question: $q, index: (i, l))
}
.onChange(of: quiz.next) { _, new in
withAnimation {
if let new {
proxy.scrollTo(new, anchor: .top)
}
}
}
}
}
}
}
/// Type of question.
enum QuestionType: String, Codable {
/// Multiple-choice question.
case multiple
/// Single-choice question.
case single
}
struct Question: Identifiable, Equatable, Codable {
let id: UUID = UUID()
/// Question or statement.
let question: String
/// Type of question.
let type: QuestionType
/// Options for the question.
var options: IdentifiedArrayOf<Option>
private enum CodingKeys: String, CodingKey { case question, type, options }
/// Indices of options that have been selected.
var selected: [Int] {
self.options.enumerated().compactMap { index, element in
element.selected ? index : nil
}
}
/// Whether changes have been made to the question.
var is_answered: Bool { self.options.contains { $0.selected } }
/// Callback function when an option is pressed.
mutating func select(id: UUID) {
guard let selected: Bool = self.options[id: id]?.selected else {
return
}
switch self.type {
case .multiple: break
case .single:
if !selected {
self.options.indices.forEach { x in
self.options[x].selected = false
}
}
}
self.options[id: id]!.selected.toggle()
}
/// Checks if two questions are equal..
static func == (lhs: Question, rhs: Question) -> Bool {
return lhs.id == rhs.id
}
}
struct QuestionView: View {
/// Question to display.
@Binding var question: Question
/// Index of question.
var index: (Int, Int)? = nil
var body: some View {
Section {
VStack {
if let index {
Text("Question \(index.0) of \(index.1)")
.padding(.top, 7.5)
.padding(.bottom, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.foregroundStyle(.secondary)
.bold()
} else { Spacer(minLength: 8) }
Text(question.question)
.padding(.bottom, 7.5)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
}
ForEach(question.options) { o in
OptionView(option: o, type: question.type)
.onTapGesture {
question.select(id: o.id)
}
}
}
}
}
/// An option for a question. Can be used in multiple-choice and single-choice questions.
struct Option: Identifiable, Codable {
let id: UUID = UUID()
/// Description of option.
let desc: String
/// Whether the option is selected.
var selected: Bool = false
private enum CodingKeys: String, CodingKey { case desc, selected }
}
struct OptionView: View {
/// Option to display.
let option: Option
/// Name of System Image (SF Symbol) when checked.
var symbolChecked: String
var symbolUnchecked: String
init(option: Option, type: QuestionType) {
self.option = option
switch type {
case .single:
self.symbolChecked = "checkmark.circle.fill"
self.symbolUnchecked = "circle"
case .multiple:
self.symbolChecked = "checkmark.square.fill"
self.symbolUnchecked = "square"
}
}
var body: some View {
ZStack {
Text(option.desc)
.frame(maxWidth: .infinity, alignment: .leading)
if option.selected {
Image(systemName: symbolChecked)
.symbolRenderingMode(.multicolor)
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
Image(systemName: symbolUnchecked)
.symbolRenderingMode(.palette)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.contentShape(Rectangle())
}
}
struct PreviewView: View {
var quizz: QuizViewModel = .init(questions: .init())
init() {
for i in 1...20 {
quizz.questions.append(
.init(question: "Question \(i)", type: .single, options: [
.init(desc: "Yes"),
.init(desc: "No"),
])
)
}
}
var body: some View {
QuizView(quiz: quizz)
}
}
#Preview {
PreviewView()
}
EDIT Updated to use a background mask instead of setting insets.
The list row separators seem to have the appearance of a standard Divider
, so one way to extend the divider to the left side is to use a .listRowBackground
with a Divider
on its lower edge.
However, the list row separators are semi-transparent, so you need to avoid any overlap. One way would be to set the width of the divider, but this means, you need to know the size of the leading inset. So another way is to apply a background to the row content. This mask needs to be extended to the trailing and bottom edges using negative padding, to compensate for the (unknown) insets.
List {
Section {
VStack {
Text("Question 1")
.padding(.top, 7.5)
.padding(.bottom, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.foregroundStyle(.secondary)
.bold()
Text("The question")
.padding(.bottom, 7.5)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(
Color(UIColor.secondarySystemGroupedBackground)
.padding(.trailing, -40) // must be >= the trailing inset
.padding(.bottom, -40) // must be >= the bottom inset
)
.listRowBackground(
Color(UIColor.secondarySystemGroupedBackground)
.overlay(alignment: .bottom) {
Divider()
}
)
ForEach(1...3, id: \.self) { i in
Text("Option \(i)")
}
}
}