feat: 纸质照片测试

This commit is contained in:
Junhui Chen 2025-08-21 16:05:22 +08:00
parent 28a9db04ab
commit abdcea18c2
7 changed files with 573 additions and 0 deletions

1
.gitignore vendored
View File

@ -0,0 +1 @@
wake.xcodeproj/xcuserdata/fairclip.xcuserdatad/screenshot-20250821-154302.png

View File

@ -80,6 +80,17 @@ struct ContentView: View {
.cornerRadius(8) .cornerRadius(8)
} }
//
NavigationLink(destination: TestView()) {
Text("TestView")
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.brown)
.foregroundColor(.white)
.cornerRadius(8)
}
// //
NavigationLink(destination: SubscribeView()) { NavigationLink(destination: SubscribeView()) {
Text("Subscribe") Text("Subscribe")

View File

@ -29,6 +29,7 @@
<string>Quicksand-SemiBold.ttf</string> <string>Quicksand-SemiBold.ttf</string>
<string>Quicksand-Medium.ttf</string> <string>Quicksand-Medium.ttf</string>
<string>Quicksand-Light.ttf</string> <string>Quicksand-Light.ttf</string>
<string>LavishlyYours-Regular.ttf</string>
</array> </array>
</dict> </dict>
</plist> </plist>

Binary file not shown.

View File

@ -6,6 +6,7 @@ enum FontFamily: String, CaseIterable {
case sankeiCute = "SankeiCutePopanime" // case sankeiCute = "SankeiCutePopanime" //
case quicksandRegular = "Quicksand-Regular" // case quicksandRegular = "Quicksand-Regular" //
case quicksandBold = "Quicksand-Bold" case quicksandBold = "Quicksand-Bold"
case lavishlyYours = "LavishlyYours-Regular"
// case // case
// : case anotherFont = "AnotherFontName" // : case anotherFont = "AnotherFontName"

View File

@ -0,0 +1,559 @@
import SwiftUI
import UIKit
// MARK: - TestView
/// + +
struct TestView: View {
@State private var slider: Double = 0.6
var body: some View {
ZStack {
// +
BackgroundBokeh()
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
//
VStack(spacing: 6) {
Text("MemoryWake")
.font(Typography.font(for: .headline, family: .sankeiCute, size: 30))
.kerning(1.2)
.foregroundStyle(Color.black.opacity(0.85))
.shadow(color: .black.opacity(0.06), radius: 6, x: 0, y: 3)
}
.padding(.top, 26)
//
PaperPhotoCard()
.padding(.horizontal, 24)
.padding(.top, 6)
//
// VStack(spacing: 12) {
// ZStack {
// RoundedRectangle(cornerRadius: 16, style: .continuous)
// .fill(.ultraThinMaterial)
// .overlay { RoundedRectangle(cornerRadius: 16).stroke(Color.white.opacity(0.35), lineWidth: 0.5) }
// .shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: 6)
// VStack(spacing: 8) {
// HStack {
// Text("2014")
// .font(Typography.font(for: .caption))
// .foregroundStyle(Color.black.opacity(0.55))
// Spacer()
// Text("2024")
// .font(Typography.font(for: .caption))
// .foregroundStyle(Color.black.opacity(0.55))
// }
// .padding(.horizontal, 14)
// Slider(value: $slider, in: 0...1)
// .tint(Theme.Colors.primary)
// .padding(.horizontal, 14)
// .padding(.bottom, 8)
// }
// .padding(.vertical, 8)
// }
// .frame(height: 76)
// .padding(.horizontal, 24)
// //
// Button {
// // Action placeholder
// } label: {
// Text("Oc cinor rnand")
// .font(Typography.font(for: .body, family: .quicksandBold))
// .foregroundStyle(Color.white)
// .padding(.vertical, 14)
// .frame(maxWidth: .infinity)
// .background(
// Capsule(style: .continuous)
// .fill(LinearGradient(colors: [Theme.Colors.primaryDark, Theme.Colors.primary], startPoint: .leading, endPoint: .trailing))
// )
// .overlay { Capsule().stroke(Color.white.opacity(0.3), lineWidth: 0.6) }
// .shadow(color: Theme.Shadows.medium, radius: 10, x: 0, y: 6)
// }
// .padding(.horizontal, 48)
// .padding(.bottom, 24)
// }
}
.padding(.bottom, 40)
}
}
}
}
// MARK: -
private struct BackgroundBokeh: View {
var body: some View {
ZStack {
Theme.Gradients.backgroundGradient
.ignoresSafeArea()
.overlay(Color.white.opacity(0.35).blendMode(.softLight))
// Canvas &
Canvas { context, size in
func circle(_ center: CGPoint, _ radius: CGFloat, _ color: Color) {
let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
context.fill(Path(ellipseIn: rect), with: .color(color.opacity(0.45)))
}
circle(CGPoint(x: size.width * 0.2, y: size.height * 0.2), 90, .white)
circle(CGPoint(x: size.width * 0.85, y: size.height * 0.18), 70, .white)
circle(CGPoint(x: size.width * 0.75, y: size.height * 0.65), 110, .white)
circle(CGPoint(x: size.width * 0.15, y: size.height * 0.75), 80, .white)
}
.blur(radius: 22)
.blendMode(.screen)
.opacity(0.8)
}
}
}
// MARK: -
private struct PaperPhotoCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack {
// +
let radius: CGFloat = 14
let deckle = DeckleShape(cornerRadius: radius, amplitude: 2.0, frequency: 48, seed: 1337)
deckle
.fill(
LinearGradient(colors: [Color.white, Color(hex: "F5EEE4")], startPoint: .top, endPoint: .bottom)
)
//
.overlay {
NoiseOverlay(opacity: 0.09)
.clipShape(deckle)
}
//
.overlay {
deckle
.stroke(LinearGradient(colors: [Color.white.opacity(0.65), .clear], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 1)
}
//
.overlay {
deckle
.stroke(LinearGradient(colors: [.clear, Color.black.opacity(0.12)], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 1)
}
// /线
.overlay { EdgeFibers().clipShape(deckle) }
// 沿
.overlay { DeckleOverlay(cornerRadius: radius, amount: 1.6, amplitude: 2.0, frequency: 48, seed: 1337) }
//
.modifier(InnerShadow(cornerRadius: radius, shadow: .black.opacity(0.15), radius: 10, x: 0, y: 4))
//
.shadow(color: .black.opacity(0.16), radius: 12, x: 0, y: 10) //
//
.shadow(color: .black.opacity(0.22), radius: 8, x: -4, y: 7) //
.shadow(color: .black.opacity(0.18), radius: 8, x: 3, y: 8) //
//
.shadow(color: .black.opacity(0.12), radius: 2, x: -2, y: 2)
.shadow(color: .white.opacity(0.7), radius: 1, x: -1, y: -1)
VStack(alignment: .leading, spacing: 12) {
//
ImageAtPath(path: "/Users/fairclip/Documents/Projects/wake-ios/wake.xcodeproj/xcuserdata/fairclip.xcuserdatad/screenshot-20250821-154302.png")
.clipShape(DeckleShape(cornerRadius: 8, amplitude: 0.8, frequency: 32, seed: 1338))
.overlay { DeckleShape(cornerRadius: 8, amplitude: 0.8, frequency: 32, seed: 1338).stroke(Color.black.opacity(0.12), lineWidth: 0.6) }
.shadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 4)
.padding(.top, 14)
.clipped()
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text("You uploaded your portrait, and chose to remember the moments you almost let slip away.")
.font(Typography.font(for: .title, family: .lavishlyYours))
.foregroundStyle(Color.black.opacity(0.45))
}
}
.padding(.horizontal, 14)
.padding(.bottom, 26)
}
.padding(.horizontal, 12)
//
// VStack { Spacer(minLength: 0)
// Capsule(style: .continuous)
// .fill(Color.black.opacity(0.08))
// .frame(width: 36, height: 8)
// .blur(radius: 0.2)
// .offset(y: 10)
// }
}
}
}
}
// MARK: - +
private struct ImageCard: View {
var body: some View {
ZStack {
//
LinearGradient(colors: [Color(hex: "E6E0D8"), Color(hex: "C9BBAA")], startPoint: .top, endPoint: .bottom)
.overlay(
//
RadialGradient(colors: [Color.white.opacity(0.75), .clear], center: .topTrailing, startRadius: 4, endRadius: 220)
.blendMode(.screen)
)
//
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 3).fill(Color(hex: "7B5134"))
.frame(height: 54)
.overlay {
LinearGradient(colors: [Color(hex: "9C6A46"), Color(hex: "5E3D28")], startPoint: .topLeading, endPoint: .bottomTrailing)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
.shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 8)
RoundedRectangle(cornerRadius: 3).fill(Color(hex: "6A462F"))
.frame(height: 20)
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
//
Image(systemName: "bicycle")
.resizable()
.scaledToFit()
.frame(width: 36, height: 36)
.foregroundStyle(Color.white)
.shadow(color: .black.opacity(0.35), radius: 6, x: 0, y: 4)
}
.frame(height: 400)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
// MARK: -
private struct NoiseOverlay: View {
let opacity: CGFloat
var body: some View {
Canvas { context, size in
let rect = CGRect(origin: .zero, size: size)
//
let count = Int(size.width * size.height / 800)
for _ in 0..<count {
let x = CGFloat.random(in: 0...size.width)
let y = CGFloat.random(in: 0...size.height)
let r = CGFloat.random(in: 0.3...0.8)
let alpha = Double.random(in: 0.15...0.55)
let color = Color.black.opacity(alpha)
context.fill(Path(ellipseIn: CGRect(x: x, y: y, width: r, height: r)), with: .color(color))
}
//
context.stroke(Path(rect), with: .color(.black.opacity(0.08)), lineWidth: 0.5)
}
.opacity(opacity)
.blendMode(.multiply)
}
}
// MARK: -
private struct EdgeFibers: View {
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
Canvas { context, _ in
// 线
let fibers = Int((w + h) * 1.6) //
for i in 0..<fibers {
let side = i % 4
let len: CGFloat = .random(in: 5...14)
let offset: CGFloat = .random(in: 0...((side % 2 == 0) ? w : h))
let inset: CGFloat = .random(in: 0...10)
var p = Path()
var start = CGPoint.zero
var end = CGPoint.zero
switch side {
case 0: // top
start = CGPoint(x: offset, y: inset)
end = CGPoint(x: offset + .random(in: -1...1), y: inset + len)
case 1: // right
start = CGPoint(x: w - inset, y: offset)
end = CGPoint(x: w - inset - len, y: offset + .random(in: -1...1))
case 2: // bottom
start = CGPoint(x: offset, y: h - inset)
end = CGPoint(x: offset + .random(in: -1...1), y: h - inset - len)
default: // left
start = CGPoint(x: inset, y: offset)
end = CGPoint(x: inset + len, y: offset + .random(in: -1...1))
}
p.move(to: start)
p.addLine(to: end)
let tone = Color(hex: "A68E72").opacity(Double.random(in: 0.08...0.18))
context.stroke(p, with: .color(tone), lineWidth: CGFloat.random(in: 0.4...1.2))
}
//
let insetBand: CGFloat = 2
let rr = RoundedRectangle(cornerRadius: 14, style: .continuous)
let outer = Path(rr.path(in: CGRect(x: 0, y: 0, width: w, height: h)).cgPath)
let inner = Path(rr.path(in: CGRect(x: insetBand, y: insetBand, width: w - insetBand*2, height: h - insetBand*2)).cgPath)
var ring = outer
ring.addPath(inner)
context.fill(ring, with: .color(Color.black.opacity(0.04)))
}
.blendMode(.multiply)
.opacity(1)
}
}
}
// MARK: -
private struct DeckleShape: Shape {
var cornerRadius: CGFloat
var amplitude: CGFloat //
var frequency: Int //
var seed: UInt64 = 0
func path(in rect: CGRect) -> Path {
let r = min(cornerRadius, min(rect.width, rect.height) / 2)
let steps = max(8, frequency)
var points: [CGPoint] = []
// 线
var state = seed == 0 ? 0x9E3779B97F4A7C15 : seed
func rand() -> CGFloat {
state &+= 0xBF58476D1CE4E5B9
var z = state
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
let v = Double((z ^ (z >> 31)) & 0x1FFFFFFFFFFFFF) / Double(0x1FFFFFFFFFFFFF)
return CGFloat(v)
}
func jitter(_ normal: CGPoint) -> CGPoint {
let a = (rand() * 2 - 1) * amplitude
return CGPoint(x: normal.x * a, y: normal.y * a)
}
//
let left = rect.minX
let right = rect.maxX
let top = rect.minY
let bottom = rect.maxY
// : top -> right -> bottom -> left
// r
// Top straight
if rect.width > 2*r {
for i in 0...steps {
let t = CGFloat(i) / CGFloat(steps)
let x = left + r + t * (rect.width - 2*r)
let base = CGPoint(x: x, y: top)
let n = CGPoint(x: 0, y: -1)
points.append(base + jitter(n))
}
}
// Top-right corner arc
let arcSteps = max(6, steps/3)
for i in 0...arcSteps {
let ang = CGFloat.pi * 1.5 + CGFloat(i) / CGFloat(arcSteps) * (.pi/2)
let center = CGPoint(x: right - r, y: top + r)
let base = CGPoint(x: center.x + cos(ang) * r, y: center.y + sin(ang) * r)
let normal = CGPoint(x: cos(ang), y: sin(ang))
points.append(base + jitter(normal))
}
// Right straight
if rect.height > 2*r {
for i in 0...steps {
let t = CGFloat(i) / CGFloat(steps)
let y = top + r + t * (rect.height - 2*r)
let base = CGPoint(x: right, y: y)
let n = CGPoint(x: 1, y: 0)
points.append(base + jitter(n))
}
}
// Bottom-right arc
for i in 0...arcSteps {
let ang = 0 + CGFloat(i) / CGFloat(arcSteps) * (.pi/2)
let center = CGPoint(x: right - r, y: bottom - r)
let base = CGPoint(x: center.x + cos(ang) * r, y: center.y + sin(ang) * r)
let normal = CGPoint(x: cos(ang), y: sin(ang))
points.append(base + jitter(normal))
}
// Bottom straight
if rect.width > 2*r {
for i in 0...steps {
let t = CGFloat(i) / CGFloat(steps)
let x = right - r - t * (rect.width - 2*r)
let base = CGPoint(x: x, y: bottom)
let n = CGPoint(x: 0, y: 1)
points.append(base + jitter(n))
}
}
// Bottom-left arc
for i in 0...arcSteps {
let ang = .pi/2 + CGFloat(i) / CGFloat(arcSteps) * (.pi/2)
let center = CGPoint(x: left + r, y: bottom - r)
let base = CGPoint(x: center.x + cos(ang) * r, y: center.y + sin(ang) * r)
let normal = CGPoint(x: cos(ang), y: sin(ang))
points.append(base + jitter(normal))
}
// Left straight
if rect.height > 2*r {
for i in 0...steps {
let t = CGFloat(i) / CGFloat(steps)
let y = bottom - r - t * (rect.height - 2*r)
let base = CGPoint(x: left, y: y)
let n = CGPoint(x: -1, y: 0)
points.append(base + jitter(n))
}
}
// Top-left arc
for i in 0...arcSteps {
let ang = .pi + CGFloat(i) / CGFloat(arcSteps) * (.pi/2)
let center = CGPoint(x: left + r, y: top + r)
let base = CGPoint(x: center.x + cos(ang) * r, y: center.y + sin(ang) * r)
let normal = CGPoint(x: cos(ang), y: sin(ang))
points.append(base + jitter(normal))
}
//
var p = Path()
guard let first = points.first else { return p }
p.move(to: first)
for pt in points.dropFirst() { p.addLine(to: pt) }
p.closeSubpath()
return p
}
}
// CGPoint 便
private extension CGPoint {
static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint { CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) }
}
// MARK: - /
private struct DeckleOverlay: View {
var cornerRadius: CGFloat
var amount: CGFloat = 1.0 // 0.5~2.0
var amplitude: CGFloat = 2.0
var frequency: Int = 48
var seed: UInt64 = 0
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
Canvas { context, _ in
// 沿
let perimeter = 2 * (w + h)
let count = Int(CGFloat(perimeter) / 4 * max(0.4, amount))
for i in 0..<count {
let side = i % 4
let offset = CGFloat.random(in: 0...((side % 2 == 0) ? w : h))
let jitter: CGFloat = .random(in: -2...2)
let dist: CGFloat = .random(in: -1.8...1.8) //
var center = CGPoint.zero
switch side {
case 0: center = CGPoint(x: offset, y: 0 + jitter + dist) // top
case 1: center = CGPoint(x: w + jitter - dist, y: offset) // right
case 2: center = CGPoint(x: offset, y: h + jitter - dist) // bottom
default: center = CGPoint(x: 0 + jitter + dist, y: offset) // left
}
let rw = CGFloat.random(in: 0.6...1.6)
let rh = CGFloat.random(in: 0.6...1.6)
let rect = CGRect(x: center.x - rw/2, y: center.y - rh/2, width: rw, height: rh)
let alpha = Double.random(in: 0.12...0.35)
//
context.fill(Path(ellipseIn: rect), with: .color(Color.black.opacity(alpha * 0.6)))
context.fill(Path(ellipseIn: rect.insetBy(dx: -0.4, dy: -0.4)), with: .color(Color.white.opacity(alpha * 0.35)))
}
}
// /
.blendMode(.overlay)
.overlay(
Canvas { context, _ in
// 沿
let steps = 220
for i in 0..<steps {
let t = CGFloat(i) / CGFloat(steps)
// roundedRect 线
var pt = CGPoint.zero
let per = t * 4
let edge = Int(per)
let u = per - CGFloat(edge)
switch edge {
case 0: pt = CGPoint(x: u * w, y: 0)
case 1: pt = CGPoint(x: w, y: u * h)
case 2: pt = CGPoint(x: (1 - u) * w, y: h)
default: pt = CGPoint(x: 0, y: (1 - u) * h)
}
let offset = CGPoint(x: pt.x + .random(in: -2...2), y: pt.y + .random(in: -2...2))
let s = CGFloat.random(in: 0.3...1.0)
context.fill(Path(ellipseIn: CGRect(x: offset.x, y: offset.y, width: s, height: s)), with: .color(Color.black.opacity(0.08)))
}
}
.blendMode(.multiply)
)
}
.allowsHitTesting(false)
.clipShape(DeckleShape(cornerRadius: cornerRadius, amplitude: amplitude, frequency: frequency, seed: seed))
}
}
// MARK: -
private struct InnerShadow: ViewModifier {
var cornerRadius: CGFloat
var shadow: Color
var radius: CGFloat
var x: CGFloat
var y: CGFloat
func body(content: Content) -> some View {
content
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color.clear, lineWidth: 0)
.shadow(color: shadow, radius: radius, x: x, y: y)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.blendMode(.multiply)
)
}
}
#Preview("TestView - Light") {
TestView()
}
#Preview("TestView - Dark") {
TestView()
.preferredColorScheme(.dark)
}
// MARK: - / Assets
private struct ImageAtPath: View {
let path: String
var body: some View {
Group {
if let ui = UIImage(contentsOfFile: path) {
Image(uiImage: ui)
.resizable()
.scaledToFit()
} else {
ZStack {
Color.black.opacity(0.04)
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.system(size: 28))
.foregroundStyle(Color.black.opacity(0.4))
Text("未找到图片")
.font(.caption)
.foregroundStyle(Color.black.opacity(0.5))
Text(path)
.font(.caption2)
.foregroundStyle(Color.black.opacity(0.4))
.multilineTextAlignment(.center)
.lineLimit(2)
}
.padding()
}
.clipped()
}
}
.frame(maxWidth: .infinity)
.clipped()
}
}