You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在SwiftUI的List中让Section内的Divider拉伸至最左侧?

iOS 17健康App心理健康测评界面复刻问题

近期我体验了iOS 17中Apple Health app的心理健康测评功能:

心理健康测评界面

我很喜欢该应用完成题目后自动滚动至下一题的交互,UI设计也十分赏心悦目,于是尝试用SwiftUI复刻该界面,已经做出了相近的效果:

我的复刻效果

但有一处细节一直困扰我:问题与选项之间的水平分隔线(红色圈出部分)无法像原应用那样拉伸至最左侧,希望实现这一效果。我在QuestionView中使用了Section,QuizView中使用了List,恳请提供SwiftUI实现分隔线拉伸的方法,同时也期待对复刻版本的反馈。以下是我的源码:

//
//  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()
}

内容的提问来源于stack exchange,提问作者Mycelium

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.12 14:34:55