docs(mypage): 공지 롤링 리뷰와 QA 증거를 보존한다

This commit is contained in:
Yu Sung
2026-10-28 17:16:18 +09:00
parent e0e59e5acf
commit d664448f38
199 changed files with 10167 additions and 0 deletions
@@ -0,0 +1,48 @@
# P1-T2 제목 전용 롤링 증거
제품 변경은 `SodaLive/Sources/MyPage/MyPageView.swift`의 `UpdateBannerView` 표시 구조뿐이다. `Text`의 identity·기존 비대칭 전환을 제목용 `ZStack` 안에 두고 그 영역을 clip했다. 화살표는 고정된 `HStack` 형제이며 Button에는 identity·전환이 없다. 기존 if-let, 현재 공지 상세 callback, padding 이후 contentShape, 글꼴·색상·제목 폭, 5초 Timer·생명주기·Reduce Motion 경로는 유지했다.
제품 SHA-256: `af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed`.
작업 전 SHA-256: `7f8ac15573ab4c9fe01508431297e40d2e95a22ad37585859f03224475749d02`.
현재 파일의 배너 부분을 기존 retained 배너로 메모리에서 치환한 해시가 작업 전 파일과 일치한다. 배너 밖 기존 작업은 그대로다. 이 확인은 변경 범위 증명이며 UI 동작 테스트가 아니다.
## 네이티브 확인
기존 `../native-fix/rerun-154222/source/Harness.swift`를 재사용하고 임시 복사본의 `run()` 시나리오만 축소했다. 루트 뷰·캡처 구현은 동일하다. 현재 제품 배너와 실제 helper/DTO/font/spacing/color를 추출하여 UIKit `UIHostingController`로 컴파일했다. Pretendard-Regular 폰트도 앱에 포함했다. 전체 SodaLive 앱이나 웹 모사가 아니다. 입력과 대체한 AppState sink·LanguageOption은 [provenance.json](provenance.json), 실행한 정확한 명령·exit·출력은 [commands.json](commands.json), 컴파일 입력은 [source/](source/)에 있다.
전용 iPhone SE3 / iOS 18.3 / 375pt 폭에서 새 native `drawHierarchy` PNG 10장을 생성했다. 모든 PNG의 signature·750×1334 크기·추출 배너 이후 생성 여부를 검사했다. [전체 배너 비교판](banner-contact-sheet.png) 및 원본 [frames/](frames/)를 직접 열어 확인했다. 원본 PNG를 수정하지 않았으며 `*-banner.png`는 명시적인 관찰용 crop이다.
| 시나리오 | 근거와 관측 |
|---|---|
| 2개 첫 교체 | `frames/two-rest.png` 첫째 → `two-end.png` 둘째, 제목 영역 변경 2,213px |
| 마지막 → 처음의 rest/mid/end | `frames/wrap-rest.png` 둘째 → `wrap-mid.png` 두 제목의 세로 이동·제목 영역 경계 clipping → `wrap-end.png` 첫째. 제목 변경은 rest→mid 3,857px, mid→end 3,796px |
| 1개 고정 | `frames/one-rest.png`와 `one-after-6s.png`의 제목·화살표 영역 동일 |
| 긴 한글 제목 | `frames/long-dark.png`, `long-light.png`: 한 줄 ellipsis, 화살표 비겹침, 배너 영역 동일 |
| 화살표 고정 | 10장 모두 crop `[654,267,702,339)`에서 변경 픽셀 0. 화살표 실제 nonblack 경계 `[672,289,689,318)`도 동일 |
화살표 RGB 해시: `bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6`.
제목 관찰 영역은 `[48,267,638,339)`, 배너는 `[0,267,750,339)`이며 좌표는 px이다. [comparison.json](comparison.json)에 각 프레임의 해시·신선도·픽셀 수·화살표 경계를 기록했다. wrap 종료 제목은 최초 첫째와 동일하고 wrap 시작은 둘째 종료와 동일하여 실제 순환도 대조했다.
`two-mid.png`는 예약 시각과 실제 렌더링의 차이로 전환 전에 캡처되어 rest와 같다. 이를 중간 전환의 증거로 간주하지 않는다. 실제 in-flight 증거는 `wrap-mid.png`이다. 주기를 단축하거나 제품 Timer를 교체하지 않았으며 정밀 5.000초 측정·프레임 성능 보증도 아니다.
## 명령과 결과
저장소 루트에서 실행:
```sh
python3 "docs/20261028_마이_탭_공지사항_롤링/reviews/evidence/title-only/run.py"
python3 "docs/20261028_마이_탭_공지사항_롤링/reviews/evidence/title-only/compare.py"
xcrun swiftc -frontend -parse SodaLive/Sources/MyPage/MyPageView.swift
GIT_MASTER=1 git diff --check
```
- runner 내부 `bash work/tests/check-my-page-notices.sh`: exit 0, 기존 실제 소스 회귀 검사 PASS 4줄. [focused-check.log](focused-check.log).
- 실제 추출 배너 native 컴파일 `xcrun --sdk iphonesimulator swiftc -sdk <SDK> -target x86_64-apple-ios16.6-simulator -module-cache-path <own-root>/module-cache <source/*.swift> -o <own-root>/NoticeNativeQA.app/NoticeNativeQA`: exit 0. 확장된 모든 인자는 `commands.json` 참조.
- 제품 Swift parse와 `git diff --check`: exit 0. LSP 도구는 제공되지 않았다.
- 비교 스크립트: exit 0, 화살표 불변 10장·2개 순환·1개 고정·긴 제목·배너 밖 변경 없음 확인.
## 자원 정리와 한계
소유 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7`, device는 `25EC20C1-F057-4DAD-BD61-A287C5D942C7`였다. TMPDIR·임시 캐시 환경변수를 소유 root 하위로 지정하고 atexit 정리를 등록했다. 앱 종료·전용 device shutdown/delete·root 삭제 후 부재를 확인했다. 실제 launch PID를 `ps -p <pid> -o pid=,command=`로 재확인하여 exit 1·출력 없음도 기록했다. [cleanup.json](cleanup.json)이 영수증이다. 부모 root `notice-title-only.i9lFW3`와 공유 simulator/cache는 수정하지 않았다.
물리/좌표 탭·padding hit-test·전체 상세 이동, VoiceOver·Reduce Motion 런타임 설정 변경, 다른 폭·실기기·iOS16.6 실행은 미검증이다. 전체 앱 빌드는 부모 담당이며 수행하지 않았다. API/서버 호출, 다른 제품 코드 수정, 부모 PRD/계획 수정, 새 UI 소스 문자열 테스트, 의존성 추가, 커밋, 다른 에이전트 호출은 하지 않았다. 독립 시각 리뷰 PASS를 주장하지 않으며 이 자료는 지정된 native 범위의 직접 관찰과 픽셀 비교 증거다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,20 @@
{
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7",
"device": "25EC20C1-F057-4DAD-BD61-A287C5D942C7",
"parentRootUntouched": "notice-title-only.i9lFW3",
"deviceAbsent": true,
"processAbsent": true,
"rootAbsent": true,
"appPid": 89665,
"postCleanupProcessCheck": {
"argv": [
"ps",
"-p",
"89665",
"-o",
"pid=,command="
],
"exit": 1,
"output": ""
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
import hashlib
import json
from pathlib import Path
import subprocess
from PIL import Image, ImageChops, ImageDraw
evidence = Path(__file__).resolve().parent
frames = evidence / "frames"
names = ["two-rest", "two-mid", "two-end", "wrap-rest", "wrap-mid", "wrap-end", "one-rest", "one-after-6s", "long-dark", "long-light"]
images = {name: Image.open(frames / (name + ".png")).convert("RGB") for name in names}
arrow_box = (654, 267, 702, 339)
title_box = (48, 267, 638, 339)
banner_box = (0, 267, 750, 339)
reference_arrow = images["two-rest"].crop(arrow_box)
def changed_pixels(first, second):
return sum(pixel != (0, 0, 0) for pixel in ImageChops.difference(first, second).get_flattened_data())
results = {}
sheet = Image.new("RGB", (750, len(names) * 110), "#202020")
draw = ImageDraw.Draw(sheet)
for index, name in enumerate(names):
path = frames / (name + ".png")
image = images[name]
assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
assert image.size == (750, 1334)
arrow = image.crop(arrow_box)
assert changed_pixels(reference_arrow, arrow) == 0
bounds = arrow.getbbox()
assert bounds is not None
results[name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "dimensions": list(image.size), "freshAfterExtractedSource": path.stat().st_mtime > (evidence / "source/UpdateBannerView.swift").stat().st_mtime, "arrowChangedPixels": changed_pixels(reference_arrow, arrow), "arrowRGBSha256": hashlib.sha256(arrow.tobytes()).hexdigest(), "arrowInkBoundsPx": [bounds[0] + arrow_box[0], bounds[1] + arrow_box[1], bounds[2] + arrow_box[0], bounds[3] + arrow_box[1]]}
assert results[name]["freshAfterExtractedSource"]
image.crop(banner_box).save(evidence / (name + "-banner.png"))
draw.text((8, index * 110 + 5), name, fill="white")
sheet.paste(image.crop(banner_box), (0, index * 110 + 30))
pairs = [("two-rest", "two-mid"), ("two-rest", "two-end"), ("wrap-rest", "wrap-mid"), ("wrap-mid", "wrap-end"), ("two-rest", "wrap-end"), ("two-end", "wrap-rest"), ("one-rest", "one-after-6s"), ("long-dark", "long-light")]
title_diffs = {first + " -> " + second: changed_pixels(images[first].crop(title_box), images[second].crop(title_box)) for first, second in pairs}
assert title_diffs["two-rest -> two-end"] > 0
assert title_diffs["wrap-rest -> wrap-mid"] > 0
assert title_diffs["wrap-mid -> wrap-end"] > 0
assert title_diffs["two-rest -> wrap-end"] == 0
assert title_diffs["two-end -> wrap-rest"] == 0
assert title_diffs["one-rest -> one-after-6s"] == 0
assert title_diffs["long-dark -> long-light"] == 0
provenance = json.loads((evidence / "provenance.json").read_text())
product = Path(provenance["product"])
text = product.read_text()
old_banner = (evidence.parent / "native-fix/rerun-154222/source/UpdateBannerView.swift").read_text().removeprefix("import SwiftUI\n\n").rstrip()
start = text.index("struct UpdateBannerView: View {")
end = text.index("// MARK: - Profile Section View")
reconstructed = text[:start] + old_banner + "\n\n" + text[end:]
assert hashlib.sha256(reconstructed.encode()).hexdigest() == "7f8ac15573ab4c9fe01508431297e40d2e95a22ad37585859f03224475749d02"
assert hashlib.sha256(product.read_bytes()).hexdigest() == provenance["sha256"]
report = {"productSha256": provenance["sha256"], "outsideBannerUnchanged": True, "arrowSlotPx": arrow_box, "titleRegionPx": title_box, "bannerPx": banner_box, "frames": results, "titleChangedPixels": title_diffs, "timingLimit": "two-mid was sampled before visible transition and equals rest; wrap-mid proves the native in-flight transition. Scheduling and rendering are not exact 5.000-second timing measurements."}
(evidence / "comparison.json").write_text(json.dumps(report, indent=2))
sheet.save(evidence / "banner-contact-sheet.png")
commands = json.loads((evidence / "commands.json").read_text())
launch = next(command for command in commands if "argv" in command and command["argv"][:3] == ["xcrun", "simctl", "launch"])
pid = int(launch["output"].rsplit(":", 1)[1].strip())
process = subprocess.run(["ps", "-p", str(pid), "-o", "pid=,command="], text=True, capture_output=True)
assert process.returncode == 1 and not process.stdout.strip()
cleanup = json.loads((evidence / "cleanup.json").read_text())
assert not Path(cleanup["root"]).exists()
cleanup.update({"appPid": pid, "processAbsent": True, "postCleanupProcessCheck": {"argv": process.args, "exit": process.returncode, "output": process.stdout}})
(evidence / "cleanup.json").write_text(json.dumps(cleanup, indent=2))
print(json.dumps({"arrowInvariantFrames": len(results), "titleChangedPixels": title_diffs, "outsideBannerUnchanged": True, "productSha256": provenance["sha256"]}, indent=2))
@@ -0,0 +1,195 @@
{
"productSha256": "af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed",
"outsideBannerUnchanged": true,
"arrowSlotPx": [
654,
267,
702,
339
],
"titleRegionPx": [
48,
267,
638,
339
],
"bannerPx": [
0,
267,
750,
339
],
"frames": {
"two-rest": {
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"two-mid": {
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"two-end": {
"sha256": "98a65e1f87fdbf4e2a5ac4cbc7862549b9863755a18a508b3e2b9a362230cff1",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"wrap-rest": {
"sha256": "98a65e1f87fdbf4e2a5ac4cbc7862549b9863755a18a508b3e2b9a362230cff1",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"wrap-mid": {
"sha256": "90c801bc8245634f8dcaba707ba597ed4ae324d859a442d645a254652ede8793",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"wrap-end": {
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"one-rest": {
"sha256": "a046258bed2ce2d36d3e6d33600803b18f1b6de0f23f6aec9e592121af36f044",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"one-after-6s": {
"sha256": "a046258bed2ce2d36d3e6d33600803b18f1b6de0f23f6aec9e592121af36f044",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"long-dark": {
"sha256": "47eb29b2389c6c33242e475da6815e3bdbbf169dae29c5c893d46150c7a124e6",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
},
"long-light": {
"sha256": "47eb29b2389c6c33242e475da6815e3bdbbf169dae29c5c893d46150c7a124e6",
"dimensions": [
750,
1334
],
"freshAfterExtractedSource": true,
"arrowChangedPixels": 0,
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
"arrowInkBoundsPx": [
672,
289,
689,
318
]
}
},
"titleChangedPixels": {
"two-rest -> two-mid": 0,
"two-rest -> two-end": 2213,
"wrap-rest -> wrap-mid": 3857,
"wrap-mid -> wrap-end": 3796,
"two-rest -> wrap-end": 0,
"two-end -> wrap-rest": 0,
"one-rest -> one-after-6s": 0,
"long-dark -> long-light": 0
},
"timingLimit": "two-mid was sampled before visible transition and equals rest; wrap-mid proves the native in-flight transition. Scheduling and rendering are not exact 5.000-second timing measurements."
}
@@ -0,0 +1,4 @@
PASS: actual NoticeApi/Repository public v2 GET requestPlain without Authorization; legacy list auth/timezone unchanged
PASS: actual DTO/roller 0/1/2/3, omitted/null envelope, repeated order/wrap/selection, exact HTML/date, legacy no-id, shrink/reset and lifecycle predicate
PASS: actual MyPageViewModel background delivery on main, 0/1/2/3 success, empty hides, HTTP/envelope/decode/transport failure preserves data, log-only/loading semantics
PASS: My Page notices regression checks (actual production API/Repository/VM/DTO/roller, native Combine; no server calls)
@@ -0,0 +1,187 @@
[
{
"event" : "application-didBecomeActive",
"scenario" : "preparing",
"uptime" : 1.3584730760194361
},
{
"event" : "scenario-start",
"scenario" : "two-title-only",
"uptime" : 1.5774609060026705
},
{
"elements" : [
],
"event" : "two-rest",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "two-rest.png",
"uptime" : 6.7126591140404344
},
{
"elements" : [
],
"event" : "two-mid",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "two-mid.png",
"uptime" : 6.8130604360485449
},
{
"elements" : [
],
"event" : "two-end",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "two-end.png",
"uptime" : 7.6315462980419397
},
{
"elements" : [
],
"event" : "wrap-rest",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "wrap-rest.png",
"uptime" : 11.689762036083266
},
{
"elements" : [
],
"event" : "wrap-mid",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "wrap-mid.png",
"uptime" : 11.895390465040691
},
{
"elements" : [
],
"event" : "wrap-end",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "two-title-only",
"screenshot" : "wrap-end.png",
"uptime" : 12.91269328200724
},
{
"event" : "scenario-start",
"scenario" : "one-static",
"uptime" : 13.90669221105054
},
{
"elements" : [
],
"event" : "one-rest",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "one-static",
"screenshot" : "one-rest.png",
"uptime" : 14.727093173074536
},
{
"elements" : [
],
"event" : "one-after-6s",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "one-static",
"screenshot" : "one-after-6s.png",
"uptime" : 20.693930465029553
},
{
"event" : "scenario-start",
"scenario" : "long-title",
"uptime" : 22.577453555073589
},
{
"elements" : [
],
"event" : "long-dark",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "long-title",
"screenshot" : "long-dark.png",
"uptime" : 23.391706261085346
},
{
"elements" : [
],
"event" : "long-light",
"phaseProbe" : [
{
"event" : "appear",
"phase" : "active"
}
],
"requestedPhase" : "active",
"scenario" : "long-title",
"screenshot" : "long-light.png",
"uptime" : 25.083867059089243
},
{
"event" : "title-only-finished",
"scenario" : "long-title",
"uptime" : 25.728418567101471
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,37 @@
{
"product": "SodaLive/Sources/MyPage/MyPageView.swift",
"sha256": "af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed",
"extractedBannerSha256": "56b9f87c93d58b01d9e555b0a9d34ad5c45420929a9df81e7e64c8cf64b856fe",
"retainedHarness": "docs/20261028_\ub9c8\uc774_\ud0ed_\uacf5\uc9c0\uc0ac\ud56d_\ub864\ub9c1/reviews/evidence/native-fix/rerun-154222/source/Harness.swift",
"harnessChange": "run() scenario only; rendering/sampling/root unchanged",
"dependencies": {
"MyPageNoticeRoller.swift": {
"path": "SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift",
"sha256": "1bd3f0dfc2ee59ce692291bf3079b06874e7c1012e4365105d3636928e29fe8a",
"sameAsRetained": true
},
"GetNoticeResponse.swift": {
"path": "SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift",
"sha256": "bbf55eb38659af3df7c6db29af0fa7904d0f87457ec098068fe96e282cee1977",
"sameAsRetained": true
},
"FontModifier.swift": {
"path": "SodaLive/Sources/Extensions/FontModifier.swift",
"sha256": "086fb0a19d884124a4cca638f7c8483de724ee31a61603b2cbd489aabf26a6c6",
"sameAsRetained": true
},
"Spacing.swift": {
"path": "SodaLive/Sources/UI/Theme/Spacing.swift",
"sha256": "372a73b821236c15449fa022cd628f61556f1381a16f234a4b77e833c2e588c1",
"sameAsRetained": true
},
"ColorExtension.swift": {
"path": "SodaLive/Sources/Extensions/ColorExtension.swift",
"sha256": "747347d86ade888fe9c1d30629285b4a71991bc0f21189a0d4b76ad314c32364",
"sameAsRetained": true
}
},
"timer": "Unmodified production 5s Timer / 0.35s transition",
"substitutions": "Retained local AppState routing sink and LanguageOption; no API/network; actual product font bundled",
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7"
}
@@ -0,0 +1,128 @@
import atexit
import hashlib
import json
import os
from pathlib import Path
import plistlib
import shutil
import subprocess
import tempfile
import time
repo = Path.cwd()
evidence = Path(__file__).resolve().parent
retained = evidence.parent / "native-fix/rerun-154222/source"
root = Path(tempfile.mkdtemp(prefix="notice-title-native.", dir="/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode"))
device = None
launched_pid = None
bundle = "local.sodalive.NoticeTitleOnlyQA"
commands = []
for key in ("TMPDIR", "TMP", "TEMP", "XDG_CACHE_HOME", "NODE_COMPILE_CACHE", "BUN_RUNTIME_TRANSPILER_CACHE_PATH", "CLANG_MODULE_CACHE_PATH", "SWIFT_MODULECACHE_PATH"):
os.environ[key] = str(root / key)
Path(os.environ[key]).mkdir()
def run(args, timeout=120, check=True):
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
commands.append({"argv": [str(value) for value in args], "exit": result.returncode, "output": result.stdout})
(evidence / "commands.json").write_text(json.dumps(commands, indent=2))
if check and result.returncode:
raise RuntimeError(result.stdout)
return result.stdout.strip()
def cleanup():
receipt = {"root": str(root), "device": device, "parentRootUntouched": "notice-title-only.i9lFW3"}
if device:
run(["xcrun", "simctl", "terminate", device, bundle], check=False)
run(["xcrun", "simctl", "shutdown", device], check=False)
run(["xcrun", "simctl", "delete", device], check=False)
devices = json.loads(run(["xcrun", "simctl", "list", "devices", "-j"]))
receipt["deviceAbsent"] = all(item["udid"] != device for group in devices["devices"].values() for item in group)
receipt["appPid"] = launched_pid
receipt["processAbsent"] = launched_pid is None or not run(["ps", "-p", str(launched_pid), "-o", "pid=,command="], check=False)
shutil.rmtree(root)
receipt["rootAbsent"] = not root.exists()
(evidence / "cleanup.json").write_text(json.dumps(receipt, indent=2))
atexit.register(cleanup)
source = root / "source"
source.mkdir()
product = repo / "SodaLive/Sources/MyPage/MyPageView.swift"
text = product.read_text()
banner = "import SwiftUI\n\n" + text[text.index("struct UpdateBannerView: View {"):text.index("// MARK: - Profile Section View")].rstrip() + "\n"
(source / "UpdateBannerView.swift").write_text(banner)
dependencies = {
"MyPageNoticeRoller.swift": "SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift",
"GetNoticeResponse.swift": "SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift",
"FontModifier.swift": "SodaLive/Sources/Extensions/FontModifier.swift",
"Spacing.swift": "SodaLive/Sources/UI/Theme/Spacing.swift",
"ColorExtension.swift": "SodaLive/Sources/Extensions/ColorExtension.swift",
}
for name, path in dependencies.items():
shutil.copy2(repo / path, source / name)
harness = (retained / "Harness.swift").read_text()
scenario = ''' func run() {
let first = NoticeItem(title: "첫 번째 공지 안내", content: "<p>첫 번째 전체 본문</p>", date: "2026-10-28")
let second = NoticeItem(title: "두 번째 서비스 소식", content: "<p>두 번째 전체 본문</p>", date: "2026-10-27")
let long = NoticeItem(title: "서비스 업데이트와 새로운 기능을 안내합니다 공지사항의 긴 한국어 제목을 끝까지 확인해 주세요", content: "<p>긴 제목</p>", date: "2026-10-25")
set("two-title-only", items: [first, second])
for (delay, name) in [(4.8, "two-rest"), (5.15, "two-mid"), (5.7, "two-end"), (9.8, "wrap-rest"), (10.15, "wrap-mid"), (10.7, "wrap-end")] {
after(delay) { self.sample(name) }
}
after(12) {
self.set("one-static", items: [first])
self.after(0.7) { self.sample("one-rest") }
self.after(6.7) { self.sample("one-after-6s") }
}
after(20) {
self.set("long-title", items: [long])
self.after(0.7) { self.sample("long-dark") }
self.after(1.5) { self.window?.overrideUserInterfaceStyle = .light }
self.after(2.3) { self.sample("long-light") }
self.after(3) { self.record("title-only-finished") }
}
}
'''
harness = harness[:harness.index(" func run() {")] + scenario + harness[harness.index("\n}\n\n@main"):]
(source / "Harness.swift").write_text(harness)
shutil.copytree(source, evidence / "source", dirs_exist_ok=True)
provenance = {"product": str(product.relative_to(repo)), "sha256": hashlib.sha256(product.read_bytes()).hexdigest(), "extractedBannerSha256": hashlib.sha256(banner.encode()).hexdigest(), "retainedHarness": str(retained.relative_to(repo) / "Harness.swift"), "harnessChange": "run() scenario only; rendering/sampling/root unchanged", "dependencies": {name: {"path": path, "sha256": hashlib.sha256((repo / path).read_bytes()).hexdigest(), "sameAsRetained": (repo / path).read_bytes() == (retained / name).read_bytes()} for name, path in dependencies.items()}, "timer": "Unmodified production 5s Timer / 0.35s transition", "substitutions": "Retained local AppState routing sink and LanguageOption; no API/network; actual product font bundled", "root": str(root)}
(evidence / "provenance.json").write_text(json.dumps(provenance, indent=2))
check = run(["bash", "work/tests/check-my-page-notices.sh"])
(evidence / "focused-check.log").write_text(check + "\n")
run(["xcrun", "swiftc", "-frontend", "-parse", str(product)])
app = root / "NoticeNativeQA.app"
app.mkdir()
shutil.copy2(repo / "SodaLive/Resources/Font/Pretendard-Regular.otf", app / "Pretendard-Regular.otf")
with (app / "Info.plist").open("wb") as handle:
plistlib.dump({"CFBundleIdentifier": bundle, "CFBundleExecutable": "NoticeNativeQA", "CFBundleName": "NoticeNativeQA", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "1", "MinimumOSVersion": "16.6", "LSRequiresIPhoneOS": True, "UIDeviceFamily": [1], "UILaunchScreen": {}, "UIAppFonts": ["Pretendard-Regular.otf"]}, handle)
sdk = run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])
run(["xcrun", "--sdk", "iphonesimulator", "swiftc", "-sdk", sdk, "-target", "x86_64-apple-ios16.6-simulator", "-module-cache-path", str(root / "module-cache"), *map(str, source.glob("*.swift")), "-o", str(app / "NoticeNativeQA")], timeout=180)
device = run(["xcrun", "simctl", "create", "NoticeTitleOnly-Owned", "com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation", "com.apple.CoreSimulator.SimRuntime.iOS-18-3"])
run(["xcrun", "simctl", "boot", device])
for attempt in range(2):
try:
run(["xcrun", "simctl", "bootstatus", device, "-b"], timeout=240)
break
except subprocess.TimeoutExpired:
commands.append({"event": "bootstatus-timeout", "attempt": attempt + 1})
if attempt == 1:
raise
run(["xcrun", "simctl", "install", device, str(app)])
launch = run(["xcrun", "simctl", "launch", device, bundle])
launched_pid = int(launch.rsplit(":", 1)[1].strip())
container = Path(run(["xcrun", "simctl", "get_app_container", device, bundle, "data"]))
actions = container / "Documents/matrix/actions.json"
deadline = time.monotonic() + 65
while time.monotonic() < deadline:
if actions.exists() and any(event["event"] == "title-only-finished" for event in json.loads(actions.read_text())):
break
time.sleep(1)
else:
raise RuntimeError("Native harness did not finish within 65 seconds: " + launch)
shutil.copytree(actions.parent, evidence / "frames", dirs_exist_ok=True)
assert hashlib.sha256(product.read_bytes()).hexdigest() == provenance["sha256"]
print(check)
print("Native title-only frames: " + str(evidence / "frames"))
@@ -0,0 +1,40 @@
//
// ColorExtension.swift
// SodaLive
//
// Created by klaus on 2023/08/09.
//
// https://seons-dev.tistory.com/174
//
import SwiftUI
extension Color {
init(hex: String) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double((rgb >> 0) & 0xFF) / 255.0
self.init(red: r, green: g, blue: b)
}
}
extension UIColor {
convenience init(hex: String, alpha: CGFloat = 1.0) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double((rgb >> 0) & 0xFF) / 255.0
self.init(red: r, green: g, blue: b, alpha: alpha)
}
}
@@ -0,0 +1,164 @@
//
// FontModifier.swift
// SodaLive
//
// Created by klaus on 1/23/26.
//
import SwiftUI
enum SodaTypography {
case heading1
case heading2
case heading3
case heading4
case body1
case body2
case body3
case body4
case body5
case body6
case caption1
case caption2
case caption3
var size: CGFloat {
switch self {
case .heading1:
return 24
case .heading2:
return 22
case .heading3:
return 20
case .heading4:
return 18
case .body1, .body2, .body3:
return 16
case .body4, .body5, .body6:
return 14
case .caption1, .caption2, .caption3:
return 12
}
}
func localizedFontName(locale: Locale = .autoupdatingCurrent) -> String? {
fontName(for: .localized, weight: weight, locale: locale)
}
var weight: SwiftUI.Font.Weight {
switch self {
case .heading1, .heading2, .heading3, .heading4, .body1, .body4, .caption1:
return .bold
case .body2, .body5, .caption2:
return .medium
case .body3, .body6, .caption3:
return .regular
}
}
}
enum SodaFontFamily {
case localized
case pattaya
case phosphate
}
private func resolvedLanguageCode(currentLocale: Locale) -> String? {
if let raw = UserDefaults.standard.string(forKey: "app.language"),
let option = LanguageOption(rawValue: raw),
option != .system {
return option.rawValue
}
return currentLocale.language.languageCode?.identifier
}
private func koreanFontName(for weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold:
return "Pretendard-Bold"
case .medium:
return "Pretendard-Medium"
case .light:
return "Pretendard-Light"
default:
return "Pretendard-Regular"
}
}
private func japaneseFontName(for weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold:
return "NotoSansJP-Bold"
case .medium:
return "NotoSansJP-Medium"
case .light:
return "NotoSansJP-Light"
default:
return "NotoSansJP-Regular"
}
}
private func fontName(for family: SodaFontFamily, weight: SwiftUI.Font.Weight, locale: Locale) -> String? {
switch family {
case .localized:
switch resolvedLanguageCode(currentLocale: locale) {
case "ko":
return koreanFontName(for: weight)
case "ja":
return japaneseFontName(for: weight)
default:
return nil
}
case .pattaya:
return "Pattaya-Regular"
case .phosphate:
return "PhosphateSolid"
}
}
struct FontModifier: ViewModifier {
@Environment(\.locale) private var locale
let size: CGFloat
let weight: SwiftUI.Font.Weight
let family: SodaFontFamily
func body(content: Content) -> some View {
if let fontName = fontName(for: family, weight: weight, locale: locale) {
content.font(.custom(fontName, size: size))
} else {
content.font(.system(size: size, weight: weight))
}
}
}
extension View {
func appFont(
size: CGFloat,
weight: SwiftUI.Font.Weight = .regular,
family: SodaFontFamily = .localized
) -> some View {
self.modifier(FontModifier(size: size, weight: weight, family: family))
}
func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> some View {
appFont(size: typography.size, weight: typography.weight, family: family)
}
}
extension Text {
func appFont(
size: CGFloat,
weight: SwiftUI.Font.Weight = .regular,
family: SodaFontFamily = .localized
) -> Text {
if let fontName = fontName(for: family, weight: weight, locale: .autoupdatingCurrent) {
return self.font(.custom(fontName, size: size))
}
return self.font(.system(size: size, weight: weight))
}
func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> Text {
appFont(size: typography.size, weight: typography.weight, family: family)
}
}
@@ -0,0 +1,19 @@
//
// GetNoticeResponse.swift
// SodaLive
//
// Created by klaus on 2023/08/10.
//
import Foundation
struct GetNoticeResponse: Decodable {
let totalCount: Int
let noticeList: [NoticeItem]
}
struct NoticeItem: Decodable, Hashable {
let title: String
let content: String
let date: String
}
@@ -0,0 +1,187 @@
import SwiftUI
import UIKit
enum LanguageOption: String { case system, ko, en, ja }
enum AppStep { case noticeDetail(notice: NoticeItem) }
final class AppState {
static let shared = AppState()
var captures: [[String: String]] = []
var phases: [[String: String]] = []
func setAppStep(step: AppStep) {
switch step {
case .noticeDetail(let notice):
captures.append(["title": notice.title, "content": notice.content, "date": notice.date])
}
}
}
struct PhaseProbe: View {
@Environment(\.scenePhase) private var phase
var body: some View {
Color.clear
.onAppear { AppState.shared.phases.append(["event": "appear", "phase": String(describing: phase)]) }
.onChange(of: phase) { value in AppState.shared.phases.append(["event": "change", "phase": String(describing: value)]) }
}
}
final class HarnessState: ObservableObject {
@Published var items: [NoticeItem] = []
@Published var mounted = true
@Published var phase = ScenePhase.active
@Published var scenario = "preparing"
}
struct HarnessRoot: View {
@ObservedObject var state: HarnessState
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text("NATIVE QA HARNESS").font(.headline)
Text(state.scenario).font(.caption.monospaced())
Text("Actual UpdateBannerView below").font(.caption)
}
.foregroundStyle(.white)
.padding(24)
if state.mounted && !state.items.isEmpty {
UpdateBannerView(items: state.items)
.background(PhaseProbe())
.environment(\.scenePhase, state.phase)
}
Text("Harness context / no network / no login")
.font(.caption)
.foregroundStyle(.gray)
.padding(24)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(hex: "131313"))
.environment(\.locale, Locale(identifier: "ko_KR"))
}
}
final class HarnessDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
let state = HarnessState()
let started = ProcessInfo.processInfo.systemUptime
var events: [[String: Any]] = []
let output = URL.documentsDirectory.appendingPathComponent(CommandLine.arguments.contains("--lifecycle") ? "lifecycle" : "matrix", isDirectory: true)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
UserDefaults.standard.set("ko", forKey: "app.language")
try! FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: HarnessRoot(state: state))
window.overrideUserInterfaceStyle = .dark
self.window = window
window.makeKeyAndVisible()
NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in
self?.state.phase = .inactive
self?.record("application-willResignActive")
}
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
self?.state.phase = .active
self?.record("application-didBecomeActive")
}
after(1) { self.run() }
return true
}
func after(_ seconds: Double, _ action: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: action)
}
func record(_ event: String, details: [String: Any] = [:]) {
var entry = details
entry["event"] = event
entry["uptime"] = ProcessInfo.processInfo.systemUptime - started
entry["scenario"] = state.scenario
events.append(entry)
let data = try! JSONSerialization.data(withJSONObject: events, options: [.prettyPrinted, .sortedKeys])
try! data.write(to: output.appendingPathComponent("actions.json"))
}
func elements() -> [NSObject] {
var found: [NSObject] = []
var visited = Set<ObjectIdentifier>()
func visit(_ object: NSObject, depth: Int) {
guard depth < 30, visited.insert(ObjectIdentifier(object)).inserted else { return }
if object.isAccessibilityElement { found.append(object) }
if let children = object.accessibilityElements {
for child in children { if let child = child as? NSObject { visit(child, depth: depth + 1) } }
}
let count = object.accessibilityElementCount()
if count > 0 && count < 1000 {
for index in 0..<count {
if let child = object.accessibilityElement(at: index) as? NSObject { visit(child, depth: depth + 1) }
}
}
if let view = object as? UIView { for child in view.subviews { visit(child, depth: depth + 1) } }
}
if let window { visit(window, depth: 0) }
return found
}
func sample(_ name: String, activate: Bool = false, screenshot: Bool = true) {
let nodes = elements()
var details: [String: Any] = ["elements": nodes.map { node -> [String: Any] in
let frame = node.accessibilityFrame
return ["class": String(describing: type(of: node)), "label": node.accessibilityLabel ?? "", "button": node.accessibilityTraits.contains(.button), "x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}]
details["phaseProbe"] = AppState.shared.phases
details["requestedPhase"] = String(describing: state.phase)
if activate {
let before = AppState.shared.captures.count
let button = nodes.first { node in
node.accessibilityTraits.contains(.button) && state.items.contains(where: { node.accessibilityLabel?.contains($0.title) == true })
}
details["accessibilityActivateResult"] = button?.accessibilityActivate() ?? false
details["captureDelta"] = AppState.shared.captures.count - before
details["sink"] = AppState.shared.captures
}
if screenshot, let window {
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
let image = renderer.image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }
try! image.pngData()!.write(to: output.appendingPathComponent(name + ".png"))
details["screenshot"] = name + ".png"
}
record(name, details: details)
}
func set(_ scenario: String, items: [NoticeItem]) {
state.scenario = scenario
state.items = items
state.phase = .active
state.mounted = true
record("scenario-start")
}
func run() {
let first = NoticeItem(title: "첫 번째 공지 안내", content: "<p>첫 번째 전체 본문</p>", date: "2026-10-28")
let second = NoticeItem(title: "두 번째 서비스 소식", content: "<p>두 번째 전체 본문</p>", date: "2026-10-27")
let long = NoticeItem(title: "서비스 업데이트와 새로운 기능을 안내합니다 공지사항의 긴 한국어 제목을 끝까지 확인해 주세요", content: "<p>긴 제목</p>", date: "2026-10-25")
set("two-title-only", items: [first, second])
for (delay, name) in [(4.8, "two-rest"), (5.15, "two-mid"), (5.7, "two-end"), (9.8, "wrap-rest"), (10.15, "wrap-mid"), (10.7, "wrap-end")] {
after(delay) { self.sample(name) }
}
after(12) {
self.set("one-static", items: [first])
self.after(0.7) { self.sample("one-rest") }
self.after(6.7) { self.sample("one-after-6s") }
}
after(20) {
self.set("long-title", items: [long])
self.after(0.7) { self.sample("long-dark") }
self.after(1.5) { self.window?.overrideUserInterfaceStyle = .light }
self.after(2.3) { self.sample("long-light") }
self.after(3) { self.record("title-only-finished") }
}
}
}
@main
struct NoticeQAEntry {
static func main() { UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(HarnessDelegate.self)) }
}
@@ -0,0 +1,25 @@
import Foundation
struct MyPageNoticeRoller {
private var items: [NoticeItem] = []
private(set) var index = 0
var current: NoticeItem? {
items.isEmpty ? nil : items[index]
}
mutating func update(_ items: [NoticeItem]) {
guard self.items != items else { return }
self.items = items
index = 0
}
mutating func advance() {
guard items.count > 1 else { return }
index = (index + 1) % items.count
}
func shouldRoll(isVisible: Bool, isActive: Bool) -> Bool {
items.count > 1 && isVisible && isActive
}
}
@@ -0,0 +1,20 @@
//
// Spacing.swift
// SodaLive
//
import SwiftUI
enum SodaSpacing {
static let s4: CGFloat = 4
static let s6: CGFloat = 6
static let s8: CGFloat = 8
static let s12: CGFloat = 12
static let s14: CGFloat = 14
static let s16: CGFloat = 16
static let s20: CGFloat = 20
static let s24: CGFloat = 24
static let s28: CGFloat = 28
static let s32: CGFloat = 32
static let s48: CGFloat = 48
}
@@ -0,0 +1,87 @@
import SwiftUI
struct UpdateBannerView: View {
private static let foregroundColor = Color(hex: "B0BEC5")
private static let rollInterval: TimeInterval = 5
private static let transitionDuration: TimeInterval = 0.35
let items: [NoticeItem]
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var roller = MyPageNoticeRoller()
@State private var rollTimer: Timer?
@State private var isVisible = false
var body: some View {
ZStack {
if let item = roller.current {
Button {
AppState.shared.setAppStep(step: .noticeDetail(notice: item))
} label: {
HStack(spacing: SodaSpacing.s8) {
ZStack {
Text(item.title)
.appFont(.body3)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.id(roller.index)
.transition(reduceMotion ? .identity : .asymmetric(
insertion: .move(edge: .bottom),
removal: .move(edge: .top)
))
}
.clipped()
Image(systemName: "chevron.right")
.frame(width: SodaSpacing.s24, height: SodaSpacing.s24)
.accessibilityHidden(true)
}
.foregroundColor(Self.foregroundColor)
.padding(.horizontal, SodaSpacing.s24)
.padding(.vertical, SodaSpacing.s6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.clipped()
.background(Color.black)
.onAppear {
isVisible = true
roller.update(items)
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
}
.onDisappear {
isVisible = false
stopRollTimer()
}
.onChange(of: items) { items in
roller.update(items)
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
}
.onChange(of: scenePhase) { scenePhase in
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
}
.onChange(of: reduceMotion) { reduceMotion in
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
}
}
private func restartRollTimer(scenePhase: ScenePhase, reduceMotion: Bool) {
stopRollTimer()
guard roller.shouldRoll(isVisible: isVisible, isActive: scenePhase == .active) else { return }
rollTimer = Timer.scheduledTimer(withTimeInterval: Self.rollInterval, repeats: true) { _ in
withAnimation(reduceMotion ? nil : .easeOut(duration: Self.transitionDuration)) {
roller.advance()
}
}
}
private func stopRollTimer() {
rollTimer?.invalidate()
rollTimer = nil
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB