Expandable FAQ Cells
MediumUIKitUIKitAccessibility 45 min read
Build an FAQ screen in UIKit. Each row shows a question. Tap the row and the answer opens below it. Tap again and it closes. The row height changes with a smooth animation.
Write all the layout in code. No storyboards.
struct FAQ: Identifiable, Hashable {
let id: Int
let question: String
let answer: String
}
final class FAQViewController: UITableViewController {
init(faqs: [FAQ] = FAQ.samples)
}Example flow
- The screen shows 6 questions. All are closed. Each has a chevron pointing down.
- The user taps "How do I reset my password?". The answer slides open. The chevron points up.
- The user taps "Can I use the app offline?". It opens too. The first one stays open.
- The user taps the first question again. It closes.
- The user sets text size to the largest setting. Long questions wrap to many lines. Nothing is cut.
Requirements
- Use
UITableViewDiffableDataSource. NonumberOfRowsInSection/cellForRowAtmethods. - Use self-sizing cells: Auto Layout decides the height. No
heightForRowAt. - Tapping a row toggles it. Many rows can be open at once.
- Animate the height change. Do not reload the whole table.
- The chevron points down when closed and up when open.
- Labels wrap to any number of lines and support Dynamic Type.
- If an opened answer goes below the screen, scroll so the user can see it.
What a good answer handles
- Identity. Opening a row must not make the data source think it is a new item. Explain what you use as the item identifier.
- Update in place. Explain
reconfigureItemsvsreloadItems. - Constraint warnings. No "Unable to simultaneously satisfy constraints" logs during the animation.
- Duplicate ids. A diffable snapshot crashes on them.
- VoiceOver. The row reads as a button with its state: "How do I reset my password?, Collapsed, Button".
Notes
- Target iOS 17 or later.
starter.swiftincludes sample data and a preview with a navigation controller.
Starter code
Copy this into a new Xcode project (or Swift Playgrounds) and build from here.
import UIKit
// MARK: - Model (done for you)
struct FAQ: Identifiable, Hashable {
let id: Int
let question: String
let answer: String
}
extension FAQ {
static let samples: [FAQ] = [
FAQ(id: 1,
question: "How do I reset my password?",
answer: "Open Settings, tap Account, then tap Reset Password. We send you an email with a link. The link works for 24 hours."),
FAQ(id: 2,
question: "Can I use the app offline?",
answer: "Yes. Saved items work without internet. New content downloads the next time you are online."),
FAQ(id: 3,
question: "How do I cancel my subscription?",
answer: "Subscriptions are managed by Apple. Open the Settings app, tap your name, then Subscriptions. Choose this app and tap Cancel Subscription. You keep access until the end of the paid period."),
FAQ(id: 4,
question: "Is my data private?",
answer: "Your data is encrypted on the device and on our servers. We never sell it."),
FAQ(id: 5,
question: "Which devices are supported?",
answer: "Any iPhone or iPad with iOS 17 or later."),
FAQ(id: 6,
question: "How do I contact support?",
answer: "Write to us from Settings > Help. We answer within one working day. Please include your device model and iOS version, so we can help you faster."),
]
}
// MARK: - Cell
final class FAQCell: UITableViewCell {
static let reuseID = "FAQCell"
private let questionLabel = UILabel()
private let answerLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
// TODO: build the layout with Auto Layout (labels, chevron).
// The cell must size itself to fit its text.
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with faq: FAQ, isExpanded: Bool) {
// TODO: show or hide the answer, rotate the chevron, accessibility.
questionLabel.text = faq.question
answerLabel.text = faq.answer
}
}
// MARK: - View controller
final class FAQViewController: UITableViewController {
// `nonisolated`: in projects where code is on the main actor by default
// (new Xcode projects), diffable data sources need a Sendable section type.
nonisolated private enum Section: Hashable, Sendable {
case main
}
private let faqs: [FAQ]
private var expandedIDs: Set<FAQ.ID> = []
// TODO: a UITableViewDiffableDataSource.
init(faqs: [FAQ] = FAQ.samples) {
self.faqs = faqs
super.init(style: .insetGrouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "FAQ"
tableView.register(FAQCell.self, forCellReuseIdentifier: FAQCell.reuseID)
// TODO: set up the data source and apply the first snapshot.
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
// TODO: toggle the row and animate the height change.
}
}
#Preview {
UINavigationController(rootViewController: FAQViewController())
}Solution
Try to build it first. Then compare your design with ours.