289 lines
11 KiB
Markdown
289 lines
11 KiB
Markdown
|
|
# 完成页迭代 — 完整代码(仅交付,禁止自动应用)
|
|||
|
|
|
|||
|
|
以下为审查修正后的完整代码。**关键修正**:底部「回到我的内容」改为 `navStore.switchToGrowthTab()`,不再使用 `navigationPath = NavigationPath()`,以保证从任意 Tab 进入都能回到技能页 Tab。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. CompletionView.swift(整文件替换)
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
import SwiftUI
|
|||
|
|
|
|||
|
|
// MARK: - 🏆 课程完结页 (Gesture Flow Edition)
|
|||
|
|
// 交互:左滑进入(上级控制),右滑返回(系统原生),底部点击回技能页 Tab
|
|||
|
|
struct CompletionView: View {
|
|||
|
|
let courseId: String
|
|||
|
|
let courseTitle: String?
|
|||
|
|
let completedLessonCount: Int
|
|||
|
|
|
|||
|
|
@EnvironmentObject private var navStore: NavigationStore
|
|||
|
|
@Environment(\.dismiss) private var dismiss
|
|||
|
|
|
|||
|
|
@State private var isSealed = false
|
|||
|
|
@State private var breathingOpacity: Double = 0.3
|
|||
|
|
@State private var contentOpacity: Double = 0
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
ZStack {
|
|||
|
|
Color.bgPaper.ignoresSafeArea()
|
|||
|
|
|
|||
|
|
VStack(spacing: 0) {
|
|||
|
|
Spacer().frame(height: 60)
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
|
|||
|
|
ZStack {
|
|||
|
|
Circle()
|
|||
|
|
.strokeBorder(
|
|||
|
|
isSealed ?
|
|||
|
|
AnyShapeStyle(
|
|||
|
|
LinearGradient(
|
|||
|
|
colors: [Color.brandVital, Color.cyberIris],
|
|||
|
|
startPoint: .topLeading,
|
|||
|
|
endPoint: .bottomTrailing
|
|||
|
|
)
|
|||
|
|
) :
|
|||
|
|
AnyShapeStyle(Color.inkSecondary.opacity(0.3)),
|
|||
|
|
style: StrokeStyle(lineWidth: isSealed ? 4 : 1, dash: isSealed ? [] : [5, 5])
|
|||
|
|
)
|
|||
|
|
.frame(width: 220, height: 220)
|
|||
|
|
.shadow(
|
|||
|
|
color: isSealed ? Color.brandVital.opacity(0.4) : .clear,
|
|||
|
|
radius: 20, x: 0, y: 0
|
|||
|
|
)
|
|||
|
|
.scaleEffect(isSealed ? 1.0 : 0.95)
|
|||
|
|
.opacity(isSealed ? 1.0 : breathingOpacity)
|
|||
|
|
.animation(.easeInOut(duration: 0.5), value: isSealed)
|
|||
|
|
|
|||
|
|
if isSealed {
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
Text("已完成的")
|
|||
|
|
.font(.system(size: 16, weight: .medium))
|
|||
|
|
.foregroundColor(.inkSecondary)
|
|||
|
|
|
|||
|
|
Text("第 \(completedLessonCount) 节")
|
|||
|
|
.font(.system(size: 36, weight: .bold, design: .serif))
|
|||
|
|
.foregroundStyle(
|
|||
|
|
LinearGradient(
|
|||
|
|
colors: [Color.brandVital, Color.cyberIris],
|
|||
|
|
startPoint: .topLeading,
|
|||
|
|
endPoint: .bottomTrailing
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
|||
|
|
} else {
|
|||
|
|
Text("完")
|
|||
|
|
.font(.system(size: 80, weight: .light, design: .serif))
|
|||
|
|
.foregroundColor(.inkPrimary.opacity(0.2))
|
|||
|
|
.opacity(breathingOpacity)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.contentShape(Circle())
|
|||
|
|
.onTapGesture {
|
|||
|
|
triggerInteraction()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
handleReturnToRoot()
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 4) {
|
|||
|
|
Text("回到我的内容")
|
|||
|
|
.font(.system(size: 15, weight: .medium))
|
|||
|
|
Image(systemName: "arrow.right")
|
|||
|
|
.font(.system(size: 12))
|
|||
|
|
}
|
|||
|
|
.foregroundColor(.brandVital)
|
|||
|
|
.padding(.vertical, 12)
|
|||
|
|
.padding(.horizontal, 32)
|
|||
|
|
.background(Capsule().fill(Color.brandVital.opacity(0.05)))
|
|||
|
|
}
|
|||
|
|
.opacity(contentOpacity)
|
|||
|
|
.padding(.bottom, 80)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.toolbar(.hidden, for: .navigationBar)
|
|||
|
|
.modifier(SwipeBackEnablerModifier())
|
|||
|
|
.onAppear {
|
|||
|
|
startBreathing()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func startBreathing() {
|
|||
|
|
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
|
|||
|
|
breathingOpacity = 0.8
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func triggerInteraction() {
|
|||
|
|
guard !isSealed else { return }
|
|||
|
|
let generator = UIImpactFeedbackGenerator(style: .heavy)
|
|||
|
|
generator.impactOccurred()
|
|||
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
|||
|
|
isSealed = true
|
|||
|
|
}
|
|||
|
|
withAnimation(.easeOut.delay(0.5)) {
|
|||
|
|
contentOpacity = 1.0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 回到技能页 Tab 根目录(与当前「继续学习」一致)
|
|||
|
|
private func handleReturnToRoot() {
|
|||
|
|
navStore.switchToGrowthTab()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - 侧滑返回:隐藏导航栏时仍可右滑 pop
|
|||
|
|
struct SwipeBackEnablerModifier: ViewModifier {
|
|||
|
|
func body(content: Content) -> some View {
|
|||
|
|
content.background(SwipeBackEnabler())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private struct SwipeBackEnabler: UIViewControllerRepresentable {
|
|||
|
|
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
|||
|
|
|
|||
|
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
|||
|
|
DispatchQueue.main.async {
|
|||
|
|
if let nc = uiViewController.navigationController {
|
|||
|
|
nc.interactivePopGestureRecognizer?.delegate = nil
|
|||
|
|
nc.interactivePopGestureRecognizer?.isEnabled = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. VerticalScreenPlayerView.swift(仅改动部分)
|
|||
|
|
|
|||
|
|
### 2.1 保持现有 init 与属性
|
|||
|
|
|
|||
|
|
- `navigationPath: Binding<NavigationPath>?` 保持可选,NoteTreeView / NoteListView 不传时仍不 push 完成页。
|
|||
|
|
- `isLastNode`, `courseTitle` 等保持不变。
|
|||
|
|
|
|||
|
|
### 2.2 替换「加载成功」分支:去掉占位页,仅用左滑手势 push
|
|||
|
|
|
|||
|
|
**原逻辑**(约 146–186 行):
|
|||
|
|
`TabView` 内 `ForEach(allCourseNodes)` + `CompletionPlaceholderPage().tag("wg://completion")`,外加 `.onChange(of: currentNodeId)` 在 `newId == "wg://completion"` 时 append completion。
|
|||
|
|
|
|||
|
|
**新逻辑**:
|
|||
|
|
- `TabView` 内**仅** `ForEach(allCourseNodes)`,每页在最后一节上挂 `LastPageSwipeModifier`,左滑时调用 `triggerCompletionNavigation`。
|
|||
|
|
- **删除** `CompletionPlaceholderPage` 及其 tag、**删除** `.onChange(of: currentNodeId)` 中与 `"wg://completion"` 相关的逻辑。
|
|||
|
|
- 新增 `@State private var isNavigatingToCompletion = false`(防抖),以及 `triggerCompletionNavigation()`、`LastPageSwipeModifier`。
|
|||
|
|
|
|||
|
|
**替换后的内容区代码**(直接替换原 `else if !allCourseNodes.isEmpty { ... }` 整块):
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
} else if !allCourseNodes.isEmpty {
|
|||
|
|
TabView(selection: $currentNodeId) {
|
|||
|
|
ForEach(allCourseNodes, id: \.id) { node in
|
|||
|
|
let isFirst = isFirstNodeInChapter(nodeId: node.id)
|
|||
|
|
let chapterTitle = getChapterTitle(for: node.id)
|
|||
|
|
|
|||
|
|
LessonPageView(
|
|||
|
|
courseId: courseId,
|
|||
|
|
nodeId: node.id,
|
|||
|
|
currentGlobalNodeId: $currentNodeId,
|
|||
|
|
initialScrollIndex: node.id == initialNodeId ? initialScrollIndex : nil,
|
|||
|
|
headerConfig: HeaderConfig(
|
|||
|
|
showChapterTitle: isFirst,
|
|||
|
|
chapterTitle: chapterTitle
|
|||
|
|
),
|
|||
|
|
courseTitle: courseTitle,
|
|||
|
|
navigationPath: navigationPath
|
|||
|
|
)
|
|||
|
|
.tag(node.id)
|
|||
|
|
.modifier(LastPageSwipeModifier(
|
|||
|
|
isLastPage: node.id == allCourseNodes.last?.id,
|
|||
|
|
onSwipeLeft: triggerCompletionNavigation
|
|||
|
|
))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|||
|
|
.ignoresSafeArea(.container, edges: .bottom)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 新增状态与函数(放在 Logic Helpers 区域)
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
@State private var isNavigatingToCompletion = false
|
|||
|
|
|
|||
|
|
private func triggerCompletionNavigation() {
|
|||
|
|
guard !isNavigatingToCompletion, let path = navigationPath else { return }
|
|||
|
|
isNavigatingToCompletion = true
|
|||
|
|
|
|||
|
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
|||
|
|
generator.impactOccurred()
|
|||
|
|
|
|||
|
|
let count = UserManager.shared.studyStats.lessons
|
|||
|
|
path.wrappedValue.append(CourseNavigation.completion(
|
|||
|
|
courseId: courseId,
|
|||
|
|
courseTitle: courseTitle,
|
|||
|
|
completedLessonCount: count
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|||
|
|
isNavigatingToCompletion = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 删除 onAppear 中「从完成页返回切回最后一节」逻辑
|
|||
|
|
|
|||
|
|
**删除**:
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
if currentNodeId == "wg://completion", let lastId = allCourseNodes.last?.id {
|
|||
|
|
currentNodeId = lastId
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
(去掉占位页后不再存在 `"wg://completion"` 选中的情况。)
|
|||
|
|
|
|||
|
|
### 2.5 删除 CompletionPlaceholderPage,新增 LastPageSwipeModifier
|
|||
|
|
|
|||
|
|
**删除** `CompletionPlaceholderPage` 结构体。
|
|||
|
|
|
|||
|
|
**在** `// MARK: - 📄 单页课程视图` **之前** 新增:
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
// MARK: - 最后一页左滑:仅在最后一节挂载,左滑 push 完成页
|
|||
|
|
private struct LastPageSwipeModifier: ViewModifier {
|
|||
|
|
let isLastPage: Bool
|
|||
|
|
let onSwipeLeft: () -> Void
|
|||
|
|
|
|||
|
|
func body(content: Content) -> some View {
|
|||
|
|
content
|
|||
|
|
.simultaneousGesture(
|
|||
|
|
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
|||
|
|
.onEnded { value in
|
|||
|
|
guard isLastPage else { return }
|
|||
|
|
if value.translation.width < -60,
|
|||
|
|
abs(value.translation.width) > abs(value.translation.height) {
|
|||
|
|
onSwipeLeft()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 调用方(GrowthView / ProfileView / DiscoveryView)
|
|||
|
|
|
|||
|
|
**无需改动**。CompletionView 仍为三参:`courseId`, `courseTitle`, `completedLessonCount`,依赖 `@EnvironmentObject navStore`,底部用 `switchToGrowthTab()` 回到技能页 Tab。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 小结
|
|||
|
|
|
|||
|
|
- **CompletionView**:无顶部返回、无打字机;赛博印章交互;右滑依赖 `SwipeBackEnablerModifier`;底部「回到我的内容」= `navStore.switchToGrowthTab()`。
|
|||
|
|
- **VerticalScreenPlayerView**:去掉占位页与 `onChange` 完成页逻辑;最后一节左滑用 `LastPageSwipeModifier` + `triggerCompletionNavigation` push 完成页;保留可选 `navigationPath`,笔记流不变。
|
|||
|
|
- **不修改** GrowthView / ProfileView / DiscoveryView 的 `CompletionView(...)` 调用。
|