mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-22 03:51:21 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingUnavailableView<Label: View, Description: View, Actions: View>: View {
|
||||
private let label: Label
|
||||
private let description: Description
|
||||
private let actions: Actions
|
||||
|
||||
init(
|
||||
@ViewBuilder label: () -> Label,
|
||||
@ViewBuilder description: () -> Description,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.label = label()
|
||||
self.description = description()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
ContentUnavailableView {
|
||||
label
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
actions
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
label.labelStyle(ReadingUnavailableLabelStyle())
|
||||
description
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
actions.padding(.top, 4)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadingUnavailableView where Label == SwiftUI.Label<Text, Image>, Description == Text?, Actions == EmptyView {
|
||||
init(_ title: String, systemImage: String, description: Text? = nil) {
|
||||
self.init {
|
||||
SwiftUI.Label(title, systemImage: systemImage)
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static func search(text: String) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
ContentUnavailableView.search(text: text)
|
||||
} else {
|
||||
ReadingUnavailableView(
|
||||
"无搜索结果",
|
||||
systemImage: "magnifyingglass",
|
||||
description: Text("没有找到与“\(text)”相关的内容,请尝试其他关键词。")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingUnavailableLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
configuration.icon
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
configuration.title.font(.title2.bold())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
|
||||
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingListTopMargin(_ margin: CGFloat) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
contentMargins(.top, margin, for: .scrollContent)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingSearchModifier: ViewModifier {
|
||||
@Binding var text: String
|
||||
@Binding var isPresented: Bool
|
||||
let prompt: String
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
content.searchable(text: $text, isPresented: $isPresented, prompt: prompt)
|
||||
} else {
|
||||
content
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
TextField(prompt, text: $text)
|
||||
.focused($isFocused)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.submitLabel(.search)
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 10))
|
||||
if isFocused {
|
||||
Button("取消") {
|
||||
text = ""
|
||||
isFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(ReadingTheme.canvas)
|
||||
}
|
||||
.onAppear { isFocused = isPresented }
|
||||
.onChange(of: isPresented) { isFocused = $0 }
|
||||
.onChange(of: isFocused) { isPresented = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReadingTheme {
|
||||
static let accent = adaptive(0x171717, dark: 0xF2F2F2)
|
||||
static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
|
||||
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27)
|
||||
static let paper = Color(red: 0.965, green: 0.945, blue: 0.895)
|
||||
static let ink = Color.primary
|
||||
static let canvas = adaptive(0xFCFCFC, dark: 0x111113)
|
||||
static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
|
||||
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
|
||||
static let separator = adaptive(0xE6E6E8, dark: 0x363639)
|
||||
static let onAccent = adaptive(0xFFFFFF, dark: 0x171717)
|
||||
|
||||
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
|
||||
Color(uiColor: UIColor { traits in
|
||||
let hex = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingPageHeader<Actions: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
@ViewBuilder var actions: Actions
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(ReadingTheme.ink)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 10) { actions }
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingToolbarIcon: View {
|
||||
let systemName: String
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 19, weight: .regular))
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(ReadingTheme.groupedCanvas, in: Circle())
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
enum BookReadingPresentation {
|
||||
static func hasStarted(_ book: Book) -> Bool {
|
||||
book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0
|
||||
}
|
||||
|
||||
static func progress(_ book: Book) -> Double {
|
||||
guard hasStarted(book), book.totalChapterNum > 0 else { return 0 }
|
||||
return min(1, max(0, Double(book.durChapterIndex + 1) / Double(book.totalChapterNum)))
|
||||
}
|
||||
|
||||
static func position(_ book: Book) -> String {
|
||||
if let title = book.durChapterTitle, !title.isEmpty { return title }
|
||||
guard hasStarted(book) else { return "尚未阅读" }
|
||||
return book.totalChapterNum > 0
|
||||
? "第\(book.durChapterIndex + 1)章 · 共\(book.totalChapterNum)章"
|
||||
: "已读至第\(book.durChapterIndex + 1)章"
|
||||
}
|
||||
|
||||
static func progressLabel(_ book: Book) -> String {
|
||||
guard hasStarted(book) else { return "尚未阅读" }
|
||||
guard book.totalChapterNum > 0 else { return position(book) }
|
||||
return "已读\(Int((progress(book) * 100).rounded()))%"
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingProgressLine: View {
|
||||
let value: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Capsule().fill(ReadingTheme.separator)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule().fill(ReadingTheme.accent)
|
||||
.frame(width: geometry.size.width * min(1, max(0, value)))
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookCoverArtwork: View {
|
||||
let title: String
|
||||
var author: String = ""
|
||||
var remoteURL: String?
|
||||
var width: CGFloat = 68
|
||||
var height: CGFloat = 96
|
||||
|
||||
private var coverStyle: Int {
|
||||
switch title {
|
||||
case "长夜里的灯": 0
|
||||
case "群星沉默时": 1
|
||||
case "山海入梦来": 2
|
||||
default: title.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 3 }
|
||||
}
|
||||
}
|
||||
|
||||
private var coverColor: Color {
|
||||
switch coverStyle {
|
||||
case 1: Color(red: 0.22, green: 0.35, blue: 0.44)
|
||||
case 2: Color(red: 0.71, green: 0.44, blue: 0.34)
|
||||
default: Color(red: 0.14, green: 0.15, blue: 0.16)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
|
||||
if case .success(let image) = phase {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(.black.opacity(0.15)).frame(width: max(2, width * 0.035))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.strokeBorder(.black.opacity(0.08), lineWidth: 0.5)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
coverColor
|
||||
Canvas { context, size in
|
||||
drawArtwork(context: context, size: size)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: width * 0.10) {
|
||||
Text(coverStyle == 0 ? title.map(String.init).joined(separator: "\n") : title)
|
||||
.font(.system(size: max(9, width * (coverStyle == 0 ? 0.16 : 0.18)), weight: .medium, design: .serif))
|
||||
.lineLimit(coverStyle == 0 ? 7 : 4)
|
||||
.minimumScaleFactor(0.65)
|
||||
.frame(maxWidth: coverStyle == 0 ? width * 0.32 : .infinity, alignment: .leading)
|
||||
if !author.isEmpty, width >= 50 {
|
||||
Text(author)
|
||||
.font(.system(size: max(7, width * 0.09)))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.94))
|
||||
.padding(width * 0.15)
|
||||
.padding(.top, coverStyle == 1 ? height * 0.33 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawArtwork(context: GraphicsContext, size: CGSize) {
|
||||
if coverStyle == 0 {
|
||||
let moon = CGRect(x: size.width * 0.68, y: size.height * 0.18, width: size.width * 0.17, height: size.width * 0.17)
|
||||
context.fill(Path(ellipseIn: moon), with: .color(Color(red: 0.89, green: 0.87, blue: 0.81)))
|
||||
} else if coverStyle == 1 {
|
||||
let normalizedPoints: [CGPoint] = [
|
||||
.init(x: 0.12, y: 0.19), .init(x: 0.39, y: 0.11), .init(x: 0.62, y: 0.22),
|
||||
.init(x: 0.88, y: 0.13), .init(x: 0.76, y: 0.42), .init(x: 0.33, y: 0.48)
|
||||
]
|
||||
let points = normalizedPoints.map { point in
|
||||
CGPoint(x: point.x * size.width, y: point.y * size.height)
|
||||
}
|
||||
var constellation = Path()
|
||||
constellation.addLines(points)
|
||||
context.stroke(constellation, with: .color(.white.opacity(0.22)), lineWidth: 0.6)
|
||||
for point in points {
|
||||
context.fill(Path(ellipseIn: CGRect(x: point.x, y: point.y, width: 1.5, height: 1.5)), with: .color(.white.opacity(0.7)))
|
||||
}
|
||||
} else {
|
||||
let arch = CGRect(x: size.width * 0.28, y: size.height * 0.40, width: size.width * 0.58, height: size.height * 0.48)
|
||||
context.fill(Path(roundedRect: arch, cornerRadius: size.width * 0.29), with: .color(.white.opacity(0.25)))
|
||||
}
|
||||
for index in 0..<3 {
|
||||
var horizon = Path()
|
||||
let baseline = size.height * (0.78 + CGFloat(index) * 0.065)
|
||||
horizon.move(to: CGPoint(x: 0, y: baseline))
|
||||
horizon.addCurve(
|
||||
to: CGPoint(x: size.width, y: baseline + 8),
|
||||
control1: CGPoint(x: size.width * 0.3, y: baseline + 18),
|
||||
control2: CGPoint(x: size.width * 0.6, y: baseline - 20)
|
||||
)
|
||||
context.stroke(horizon, with: .color(.white.opacity(0.17)), lineWidth: 0.7)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingCard<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(20)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let text: String
|
||||
var color: Color = ReadingTheme.accent
|
||||
var systemImage: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let systemImage { Image(systemName: systemImage) }
|
||||
Text(text)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user