feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
enum LanguageOption: String { case system, ko, en, ja }
|
||||
|
||||
func recordHarnessEvent(_ event: String) {
|
||||
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let url = directory.appendingPathComponent("events.jsonl")
|
||||
let data = try! JSONSerialization.data(withJSONObject: [
|
||||
"event": event, "time": CACurrentMediaTime(), "pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"test": ProcessInfo.processInfo.environment["SB_TEST_NAME"] ?? "manual"
|
||||
]) + Data([10])
|
||||
if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
|
||||
let handle = try! FileHandle(forWritingTo: url)
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
try! handle.close()
|
||||
}
|
||||
|
||||
final class FixtureProtocol: URLProtocol {
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FrameRecorder {
|
||||
static let shared = FrameRecorder()
|
||||
private var link: CADisplayLink?
|
||||
private var end: CFTimeInterval = 0
|
||||
private var directory: URL?
|
||||
private var frames: [[String: Any]] = []
|
||||
private var onTransition: (() -> Void)?
|
||||
|
||||
func start(_ name: String, duration: TimeInterval = 2.5, onTransition: (() -> Void)? = nil) {
|
||||
stop()
|
||||
self.onTransition = onTransition
|
||||
recordHarnessEvent("record-\(name)")
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
directory = documents.appendingPathComponent(name)
|
||||
try! FileManager.default.createDirectory(at: directory!, withIntermediateDirectories: true)
|
||||
frames = []
|
||||
end = CACurrentMediaTime() + duration
|
||||
let displayLink = CADisplayLink(target: self, selector: #selector(capture))
|
||||
displayLink.preferredFramesPerSecond = 30
|
||||
displayLink.add(to: .main, forMode: .common)
|
||||
link = displayLink
|
||||
}
|
||||
|
||||
@objc private func capture() {
|
||||
guard CACurrentMediaTime() < end else { stop(); return }
|
||||
guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene })
|
||||
.flatMap(\.windows).first(where: \.isKeyWindow), let directory else { return }
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
|
||||
}
|
||||
let filename = String(format: "%04d.png", frames.count)
|
||||
try! image.pngData()!.write(to: directory.appendingPathComponent(filename))
|
||||
frames.append(["file": filename, "time": CACurrentMediaTime()])
|
||||
try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted)
|
||||
.write(to: directory.appendingPathComponent("frames.json"))
|
||||
if onTransition != nil, hasBothEndpointImages(image) {
|
||||
recordHarnessEvent("observed-mixed-\(directory.lastPathComponent)")
|
||||
let action = onTransition
|
||||
onTransition = nil
|
||||
DispatchQueue.main.async { action?() }
|
||||
}
|
||||
}
|
||||
|
||||
private func hasBothEndpointImages(_ image: UIImage) -> Bool {
|
||||
let row = image.cgImage!.cropping(to: CGRect(x: 0, y: 240, width: 375, height: 1))!
|
||||
var pixels = [UInt8](repeating: 0, count: 375 * 4)
|
||||
pixels.withUnsafeMutableBytes { buffer in
|
||||
let context = CGContext(data: buffer.baseAddress, width: 375, height: 1, bitsPerComponent: 8,
|
||||
bytesPerRow: 375 * 4, space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
context.draw(row, in: CGRect(x: 0, y: 0, width: 375, height: 1))
|
||||
}
|
||||
var red = 0
|
||||
var green = 0
|
||||
for offset in stride(from: 0, to: pixels.count, by: 4) {
|
||||
if pixels[offset] > 200, pixels[offset + 1] < 100, pixels[offset + 2] < 100 { red += 1 }
|
||||
if pixels[offset] < 100, pixels[offset + 1] > 150, pixels[offset + 2] < 150 { green += 1 }
|
||||
}
|
||||
return red > 5 && green > 5
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
link?.invalidate()
|
||||
link = nil
|
||||
if let directory {
|
||||
try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted)
|
||||
.write(to: directory.appendingPathComponent("frames.json"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct SubBannerGestureApp: App {
|
||||
init() {
|
||||
recordHarnessEvent("launch")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [FixtureProtocol.self]
|
||||
ImageDownloader.default.sessionConfiguration = configuration
|
||||
for index in 1...3 {
|
||||
for mixed in [false, true] {
|
||||
let size = CGSize(width: 600, height: mixed && index == 2 ? 200 : 300)
|
||||
let image = UIGraphicsImageRenderer(size: size).image { context in
|
||||
[UIColor.systemRed, .systemGreen, .systemBlue][index - 1].setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(x: 18, y: 0, width: 12, height: size.height))
|
||||
context.fill(CGRect(x: size.width - 30, y: 0, width: 12, height: size.height))
|
||||
("BANNER \(index)" as NSString).draw(at: CGPoint(x: 180, y: 80), withAttributes: [
|
||||
.font: UIFont.boldSystemFont(ofSize: 36), .foregroundColor: UIColor.white
|
||||
])
|
||||
}
|
||||
ImageCache.default.store(image, forKey: Self.url(index, mixed: mixed), toDisk: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func url(_ index: Int, mixed: Bool) -> String {
|
||||
"https://gesture-fixture.invalid/\(mixed ? "mixed" : "equal")/\(index).png"
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup { GestureHarness() }
|
||||
}
|
||||
}
|
||||
|
||||
struct GestureHarness: View {
|
||||
private let arguments = ProcessInfo.processInfo.arguments
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var count = 0
|
||||
@State private var lastID = 0
|
||||
@State private var replaced = false
|
||||
@State private var shown = true
|
||||
|
||||
private var active: Bool { arguments.contains("active") }
|
||||
private var items: [SubBanner] {
|
||||
if arguments.contains("empty") { return [] }
|
||||
let amount = replaced || arguments.contains("single") ? 1 : (active ? 2 : 3)
|
||||
return (1...amount).map { index in
|
||||
SubBanner(id: replaced ? 9 : index, placement: .homeRecommendation,
|
||||
imageUrl: SubBannerGestureApp.url(index, mixed: arguments.contains("mixed")),
|
||||
webUrl: "https://gesture-fixture.invalid/link/\(index)",
|
||||
androidDeepLink: nil, iosDeepLink: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: SodaSpacing.s8) {
|
||||
Text("taps=\(count);last=\(lastID)").accessibilityIdentifier("tap-status")
|
||||
Text("Native touch regression")
|
||||
HStack {
|
||||
Button("Record") { FrameRecorder.shared.start("manual") }
|
||||
Button("Forward") { FrameRecorder.shared.start("forward") }
|
||||
Button("Replace") { recordHarnessEvent("replace"); replaced = true }
|
||||
Button(shown ? "Hide" : "Show") { recordHarnessEvent(shown ? "hide" : "show"); shown.toggle() }
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: SodaSpacing.s24) {
|
||||
if shown {
|
||||
SubBannerCarousel(items: items) { item in
|
||||
count += 1
|
||||
lastID = item.id
|
||||
recordHarnessEvent("tap-\(item.id)")
|
||||
}
|
||||
.environment(\.scenePhase, active ? scenePhase : .inactive)
|
||||
}
|
||||
Text("추천 콘텐츠").accessibilityIdentifier("after-banner")
|
||||
ForEach(0..<30) { index in
|
||||
Text("Content row \(index)").frame(maxWidth: .infinity).frame(height: 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.appFont(.body3)
|
||||
.background(Color.bg)
|
||||
.foregroundColor(.white)
|
||||
.preferredColorScheme(.dark)
|
||||
.task(id: scenePhase) {
|
||||
recordHarnessEvent("phase-\(scenePhase)")
|
||||
guard active, scenePhase == .active, !arguments.contains("background") else { return }
|
||||
do { try await Task.sleep(nanoseconds: 8_000_000_000) } catch { return }
|
||||
let name = arguments.contains("replace-during-wrap") ? "replace" : (arguments.contains("hide-during-wrap") ? "hide" : "automatic")
|
||||
if name == "automatic" {
|
||||
FrameRecorder.shared.start(name, duration: 4)
|
||||
} else {
|
||||
FrameRecorder.shared.start(name, duration: 4) {
|
||||
recordHarnessEvent(name)
|
||||
if name == "replace" { replaced = true } else { shown = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user