Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions V2er/State/DataFlow/Model/FeedDetailInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ struct FeedDetailInfo: BaseModel {
userName == owner && owner != .empty
}

/// 获取点赞数的整数值,用于排序
var loveCount: Int {
// love 字段格式可能是 "♥ 3" 或 "3" 或空字符串
let trimmed = love.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return 0 }
// 提取第一个连续的数字片段,避免将多个数字段拼接在一起
let numberString = trimmed
.components(separatedBy: CharacterSet.decimalDigits.inverted)
.first(where: { !$0.isEmpty }) ?? ""
return Int(numberString) ?? 0
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.floor == rhs.floor
}
Expand Down
20 changes: 20 additions & 0 deletions V2er/State/DataFlow/State/FeedDetailState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@

import Foundation

enum ReplySortType: String, CaseIterable {
case byTime = "time" // 按时间排序(默认,即楼层顺序)
case byPopularity = "popularity" // 按热门排序(点赞数)

var displayName: String {
switch self {
case .byTime: return "时间"
case .byPopularity: return "热门"
}
}

var iconName: String {
switch self {
case .byTime: return "clock"
case .byPopularity: return "flame"
}
}
}

struct FeedDetailState: FluxState {
var refCounts = 0
var reseted: Bool = false
Expand All @@ -20,6 +39,7 @@ struct FeedDetailState: FluxState {
var model: FeedDetailInfo = FeedDetailInfo()
var ignored: Bool = false
var replyContent: String = .empty
var replySortType: ReplySortType = .byTime
}

typealias FeedDetailStates=[String : FeedDetailState]
76 changes: 75 additions & 1 deletion V2er/View/FeedDetail/FeedDetailPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
!state.replyContent.isEmpty
}

/// 根据当前排序方式返回排序后的回复列表
private var sortedReplies: [FeedDetailInfo.ReplyInfo.Item] {
let items = state.model.replyInfo.items
switch state.replySortType {
case .byTime:
return items // 按时间排序(保持原始楼层顺序)
case .byPopularity:
// 按点赞数降序,相同点赞数按楼层升序
return items.sorted { (a, b) in
if a.loveCount != b.loveCount {
return a.loveCount > b.loveCount
}
return a.floor < b.floor
}
}
}

private var isContentEmpty: Bool {
let contentInfo = state.model.contentInfo
return contentInfo == nil || contentInfo!.html.isEmpty
Expand Down Expand Up @@ -167,8 +184,16 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
.listRowBackground(Color.itemBg)
}

// Reply Section Header with Sort Toggle
if !state.model.replyInfo.items.isEmpty {
replySectionHeader
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.itemBg)
}

// Reply Section
ForEach(state.model.replyInfo.items) { item in
ForEach(sortedReplies, id: \.floor) { item in
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using floor as the ForEach identifier instead of the default id (UUID) property will cause SwiftUI view identity issues when sorting changes. When the sort order toggles between time and popularity, SwiftUI will incorrectly reuse views because the floor numbers don't change, leading to:

  1. Animation glitches or no animations
  2. Potential view state persistence issues (e.g., expanded states, gestures)
  3. Views not properly recreating when they should

The Item struct is already Identifiable with a UUID id property. Keep using the default identifier by changing this line back to ForEach(sortedReplies) without the id: parameter.

Suggested change
ForEach(sortedReplies, id: \.floor) { item in
ForEach(sortedReplies) { item in

Copilot uses AI. Check for mistakes.
ReplyItemView(info: item, topicId: id)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
Expand Down Expand Up @@ -291,6 +316,55 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
}
}

/// 选中项的文字颜色(与 tintColor 背景形成对比)
private var segmentSelectedTextColor: Color {
Color.dynamicHex(light: 0xFFFFFF, dark: 0x1C1C1E)
}

@ViewBuilder
private var replySectionHeader: some View {
HStack {
Text("回复")
.font(.subheadline.weight(.medium))
.foregroundColor(.primaryText)

Spacer()

// Segmented sort picker
HStack(spacing: 0) {
ForEach(ReplySortType.allCases, id: \.self) { sortType in
Button {
store.appState.feedDetailStates[instanceId]?.replySortType = sortType
} label: {
HStack(spacing: 3) {
Image(systemName: sortType.iconName)
.font(.caption2)
Text(sortType.displayName)
.font(.caption)
}
.foregroundColor(state.replySortType == sortType ? segmentSelectedTextColor : .secondaryText)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
state.replySortType == sortType
? Color.tintColor
: Color.clear
)
}
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The segmented control buttons should use .buttonStyle(.plain) to prevent default button highlighting behavior in iOS. Without this, tapping a button may show unwanted highlighting effects that conflict with the custom selected state styling. Add .buttonStyle(.plain) after the Button's label closure (line 353).

Suggested change
}
}
.buttonStyle(.plain)

Copilot uses AI. Check for mistakes.
}
}
.background(Color.secondaryBackground)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.border, lineWidth: 0.5)
)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.lightGray.opacity(0.5))
}

@ViewBuilder
private var actionBar: some View {
HStack (spacing: 10) {
Expand Down
Loading