feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회
- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변) - RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일) - DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회) - Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백 - -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
parent
f81fdc82f9
commit
5b526fa650
@ -527,7 +527,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
@ -584,7 +584,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
@ -832,7 +832,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -858,7 +858,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@ -203,7 +203,7 @@ struct WatchActionListView: View {
|
||||
store.run(action)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
@ -116,7 +116,7 @@ struct GoalRateComplicationView: View {
|
||||
case .accessoryRectangular:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.symbolName)
|
||||
Image(safeSymbol: entry.symbolName)
|
||||
.font(.system(size: 11))
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
@ -130,7 +130,7 @@ struct GoalRateComplicationView: View {
|
||||
}
|
||||
default:
|
||||
Gauge(value: min(max(entry.ratio, 0), 1)) {
|
||||
Image(systemName: entry.symbolName)
|
||||
Image(safeSymbol: entry.symbolName)
|
||||
} currentValueLabel: {
|
||||
Text("\(percent)")
|
||||
.font(.system(size: 13, weight: .bold).monospacedDigit())
|
||||
@ -261,7 +261,7 @@ struct QuestRateComplicationView: View {
|
||||
// 크기가 큰 패밀리는 하루/주간/월간 세 개를 모두 표기 (스펙 §9.2-3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: quest.symbolName)
|
||||
Image(safeSymbol: quest.symbolName)
|
||||
.font(.system(size: 11))
|
||||
Text(quest.name)
|
||||
.font(.headline)
|
||||
@ -306,7 +306,7 @@ struct QuestRateComplicationView: View {
|
||||
}
|
||||
} else {
|
||||
Gauge(value: min(max(quest.ratio(forSpanRaw: entry.spanRaw), 0), 1)) {
|
||||
Image(systemName: quest.symbolName)
|
||||
Image(safeSymbol: quest.symbolName)
|
||||
} currentValueLabel: {
|
||||
Text("\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))")
|
||||
.font(.system(size: 13, weight: .bold).monospacedDigit())
|
||||
|
||||
@ -114,7 +114,7 @@ struct StatusComplicationView: View {
|
||||
case .accessoryRectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.runningSymbol ?? "record.circle")
|
||||
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
|
||||
.font(.system(size: 11))
|
||||
Text(name)
|
||||
.font(.headline)
|
||||
@ -136,7 +136,7 @@ struct StatusComplicationView: View {
|
||||
}
|
||||
default:
|
||||
VStack(spacing: 1) {
|
||||
Image(systemName: entry.runningSymbol ?? "record.circle")
|
||||
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
if let start = entry.runningStartAt {
|
||||
Text(start, style: .timer)
|
||||
|
||||
25
myApp/HaruDanim/Haru_DanimWatchWidgets/SymbolCompat.swift
Normal file
25
myApp/HaruDanim/Haru_DanimWatchWidgets/SymbolCompat.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// SymbolCompat.swift
|
||||
// Haru_DanimWatchWidgets
|
||||
//
|
||||
// 사용자 지정 SF 심볼의 안전 렌더 — 워치 하한(watchOS 10)에는 없는 신형 심볼을
|
||||
// 아이폰(iOS 26)에서 골라 동기화하면 빈 아이콘이 되는 것을 물음표로 대치한다.
|
||||
// Shared/SymbolCompat.swift(앱·위젯)와 같은 헬퍼의 워치 컴플리케이션 사본 —
|
||||
// 폴더 동기화 그룹 멤버십이 갈려 한 파일을 공유할 수 없다.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum WatchSymbolCompat {
|
||||
static func safe(_ name: String) -> String {
|
||||
UIImage(systemName: name) != nil ? name : "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
extension Image {
|
||||
/// 사용자 데이터의 심볼 렌더 전용 — 이 OS에 없는 이름은 물음표로 대치
|
||||
init(safeSymbol name: String) {
|
||||
self.init(systemName: WatchSymbolCompat.safe(name))
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,7 @@ struct ContentView: View {
|
||||
DebugSeed.watchGoalsIfRequested(context: context)
|
||||
DebugSeed.endGoalYesterdayIfRequested(context: context)
|
||||
DebugSeed.dumpStreaksIfRequested(context: context)
|
||||
DebugSeed.dumpMissingSymbolsIfRequested()
|
||||
DebugSeed.runProgressSelfTestIfRequested()
|
||||
CSVImport.selfTestIfRequested()
|
||||
await DebugSeed.runIntentSmokeTestIfRequested()
|
||||
|
||||
@ -485,6 +485,18 @@ enum DebugSeed {
|
||||
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// `-symbolAuditDump` 런치 인자: 아이콘 카탈로그에서 현재 OS에 없는 심볼을
|
||||
/// Documents/symbol-audit.txt로 기록 — 하위 OS(iOS 18) QA용. 선택기는 어차피
|
||||
/// 런타임 필터로 걸러지므로, 이 목록은 "26 기기에서 고른 심볼이 18 기기에서
|
||||
/// 빈 아이콘이 될 수 있는 후보"를 뜻한다 (비어 있으면 교차 기기 위험 없음).
|
||||
static func dumpMissingSymbolsIfRequested() {
|
||||
guard UserDefaults.standard.bool(forKey: "symbolAuditDump") else { return }
|
||||
let missing = SymbolCatalog.missingSymbolsOnThisOS
|
||||
let lines = ["missing=\(missing.count)"] + missing
|
||||
let url = URL.documentsDirectory.appending(path: "symbol-audit.txt")
|
||||
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// `-watchGoals <N>` 런치 인자: 생성순 목표 N개를 '애플워치에서 보기'로 설정
|
||||
/// (0이면 전체 해제 — 워치 컴플리케이션 필터 검증용. 설정 후 스냅숏을 즉시 푸시)
|
||||
static func watchGoalsIfRequested(context: ModelContext) {
|
||||
|
||||
@ -25,6 +25,15 @@ enum SymbolCatalog {
|
||||
return categories.flatMap(\.symbols).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 검증용(-symbolAuditDump): 카탈로그 원본 중 현재 OS에 없는 심볼 — 하위 OS QA (§14)
|
||||
static var missingSymbolsOnThisOS: [String] {
|
||||
var seen = Set<String>()
|
||||
return rawCategories.flatMap(\.1)
|
||||
.filter { seen.insert($0).inserted && UIImage(systemName: $0) == nil }
|
||||
}
|
||||
#endif
|
||||
|
||||
private static let rawCategories: [(String, [String])] = [
|
||||
(String(localized: "인기"), [
|
||||
"star.fill", "heart.fill", "flame.fill", "bolt.fill", "leaf.fill",
|
||||
|
||||
@ -170,7 +170,7 @@ struct ActionRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
@ -263,7 +263,7 @@ struct FavoriteOrderSheet: View {
|
||||
Section {
|
||||
ForEach(favorites) { action in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
@ -347,7 +347,7 @@ struct ActionDetailView: View {
|
||||
HStack {
|
||||
Text("아이콘")
|
||||
Spacer()
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(action.color, in: RoundedRectangle(cornerRadius: 8))
|
||||
@ -461,7 +461,7 @@ struct ActionEditorView: View {
|
||||
showingSymbolPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: symbolName)
|
||||
Image(safeSymbol: symbolName)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
@ -281,7 +281,7 @@ struct DiaryDefaultsSheet: View {
|
||||
}
|
||||
)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 26, height: 26)
|
||||
@ -319,7 +319,7 @@ struct DiaryDefaultsSheet: View {
|
||||
}
|
||||
)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 26, height: 26)
|
||||
|
||||
@ -465,8 +465,8 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
|
||||
let zoomEnabled: Bool
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
func makeCoordinator() -> DiaryZoomCoordinator {
|
||||
DiaryZoomCoordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> DiaryZoomScrollView {
|
||||
@ -490,7 +490,7 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
|
||||
NSNumber(value: UITouch.TouchType.direct.rawValue)
|
||||
]
|
||||
|
||||
let hosting = UIHostingController(rootView: content())
|
||||
let hosting = UIHostingController(rootView: AnyView(content()))
|
||||
hosting.view.backgroundColor = .clear
|
||||
hosting.safeAreaRegions = []
|
||||
hosting.view.frame = CGRect(origin: .zero, size: pageSize)
|
||||
@ -516,27 +516,33 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) {
|
||||
context.coordinator.hosting?.rootView = content()
|
||||
context.coordinator.hosting?.rootView = AnyView(content())
|
||||
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
|
||||
// 배치 모드에서는 한 손가락 드래그를 요소 이동에 양보 (페이지 이동은 두 손가락)
|
||||
scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIScrollViewDelegate {
|
||||
var hosting: UIHostingController<Content>?
|
||||
/// ⚠️ 비제네릭·톱레벨 + AnyView 소거 유지(1.4 하한 18 컴파일러 우회): 예전처럼
|
||||
/// DiaryZoomContainer<Content> 안에 중첩해 UIHostingController<Content>를 들면,
|
||||
/// 하한 18 Release(-O wholemodule) 빌드에서 swift-frontend가 이 클래스의 deinit을
|
||||
/// 최적화하다 크래시한다(SILPerformanceInliner의
|
||||
/// isCallerAndCalleeLayoutConstraintsCompatible 무한 재귀 — 하한 26에서는 정상, 실측).
|
||||
/// rootView는 원래 매 갱신 통째로 교체되므로 AnyView 소거의 동작 차이는 없다.
|
||||
private final class DiaryZoomCoordinator: NSObject, UIScrollViewDelegate {
|
||||
var hosting: UIHostingController<AnyView>?
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
(scrollView as? DiaryZoomScrollView)?.zoomedView
|
||||
}
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
(scrollView as? DiaryZoomScrollView)?.zoomedView
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
|
||||
}
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
// 핀치가 끝난 배율에 맞춰 캔버스 래스터 해상도를 다시 맞춘다 (확대 시 픽셀 번짐 방지)
|
||||
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
|
||||
}
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
// 핀치가 끝난 배율에 맞춰 캔버스 래스터 해상도를 다시 맞춘다 (확대 시 픽셀 번짐 방지)
|
||||
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1565,7 +1565,7 @@ private struct DiaryGoalsCard: View {
|
||||
let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
@ -1627,7 +1627,7 @@ private struct DiaryGoalsCard: View {
|
||||
let target = quest.targetValue
|
||||
let limit = quest.direction == .atMost
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: quest.targetSymbol)
|
||||
Image(safeSymbol: quest.targetSymbol)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
@ -1798,7 +1798,7 @@ private struct DiaryGoalFilterSheet: View {
|
||||
toggle(id)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
@ -978,7 +978,7 @@ struct ExportSectionView: View {
|
||||
|
||||
private func recordRow(_ row: ExportRecordRow) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: row.symbol)
|
||||
Image(safeSymbol: row.symbol)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
@ -1082,7 +1082,7 @@ struct ExportSectionView: View {
|
||||
ForEach(group.rows) { row in
|
||||
GridRow {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: row.symbol)
|
||||
Image(safeSymbol: row.symbol)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
@ -1195,7 +1195,7 @@ private struct ExportTimetableView: View {
|
||||
.lineLimit(1)
|
||||
.padding(4)
|
||||
} else if height >= 12 {
|
||||
Image(systemName: block.symbol)
|
||||
Image(safeSymbol: block.symbol)
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
|
||||
@ -197,7 +197,7 @@ struct GoalListView: View {
|
||||
/// 순서 변경 모드에서 보여줄 간략한 목표 행
|
||||
private func reorderRow(_ goal: Goal) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
@ -249,7 +249,7 @@ struct GoalRow: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
@ -339,7 +339,7 @@ struct QuestRow: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: quest.targetSymbol)
|
||||
Image(safeSymbol: quest.targetSymbol)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
@ -641,7 +641,7 @@ struct GoalEditorView: View {
|
||||
showingSymbolPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: symbolName)
|
||||
Image(safeSymbol: symbolName)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
|
||||
@ -473,7 +473,7 @@ private struct HistoryRecordsView: View {
|
||||
onTapSession(item.session)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.action.symbolName)
|
||||
Image(safeSymbol: item.action.symbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
@ -512,7 +512,7 @@ private struct HistoryRecordsView: View {
|
||||
onTapEntry(item.entry)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.action.symbolName)
|
||||
Image(safeSymbol: item.action.symbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
@ -667,13 +667,13 @@ struct TimetableView: View {
|
||||
.fill(item.action.color.opacity(0.85))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if !weekly && height >= 18 {
|
||||
Label(item.action.name, systemImage: item.action.symbolName)
|
||||
Label(item.action.name, systemImage: SymbolCompat.safe(item.action.symbolName))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.padding(4)
|
||||
} else if height >= 14 {
|
||||
Image(systemName: item.action.symbolName)
|
||||
Image(safeSymbol: item.action.symbolName)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
@ -701,7 +701,7 @@ struct TimetableView: View {
|
||||
Circle()
|
||||
.strokeBorder(AppTheme.surface, lineWidth: 2)
|
||||
if !weekly {
|
||||
Image(systemName: item.action.symbolName)
|
||||
Image(safeSymbol: item.action.symbolName)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
@ -721,7 +721,7 @@ struct GoalSummaryCard: View {
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
@ -807,7 +807,7 @@ struct GoalSummaryCard: View {
|
||||
let progress = QuestProgress(quest: quest)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: quest.targetSymbol)
|
||||
Image(safeSymbol: quest.targetSymbol)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 18, height: 18)
|
||||
@ -905,7 +905,7 @@ struct SessionMemoSheet: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let action = session.action {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
@ -964,7 +964,7 @@ struct CountMemoSheet: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let action = entry.action {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
@ -1093,7 +1093,7 @@ struct RunningSessionRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
@ -1212,7 +1212,7 @@ struct ActionButtonCell: View {
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: compact ? 3 : 6) {
|
||||
HStack {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: compact ? 17 : 26, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.symbolEffect(.bounce, value: bounce)
|
||||
|
||||
@ -98,7 +98,7 @@ struct QuestEditorView: View {
|
||||
Picker("행동", selection: $selectedAction) {
|
||||
Text("선택").tag(Action?.none)
|
||||
ForEach(allActions) { action in
|
||||
Label(action.name, systemImage: action.symbolName)
|
||||
Label(action.name, systemImage: SymbolCompat.safe(action.symbolName))
|
||||
.tag(Optional(action))
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,9 @@
|
||||
// - 등장: 스태거(순차) 바운스 스프링 + 스월 회전. 닫힘: 지연 없이 FAB로 스냅 흡수.
|
||||
// 펼치면 메뉴 뒤로 은은한 초록 빛 무리(정적 그라데이션이라 렌더 비용 없음)가 퍼진다.
|
||||
// - 아치 반경이 엄지 반경(~150pt) 안이라 세로 스택과 달리 모든 탭이 한 손에 닿는다.
|
||||
// - iOS 18 폴백(1.4 하위 OS 지원): glassEffect 계열은 26 전용이라, 18에서는 같은 구슬을
|
||||
// 머티리얼 원(블러+하이라이트 테두리+그림자)으로 그린다 — 굴절·액체 모핑만 빠지고
|
||||
// 배치·스태거 스프링·더블탭 등 동작은 완전히 동일하다 (LiquidBallBackground 참고).
|
||||
//
|
||||
// iPhone(Compact) 전용 — iPad·Mac은 SidebarRootView, watch는 별도 타깃이라 이 파일과 무관하다.
|
||||
//
|
||||
@ -129,16 +132,15 @@ struct GlassBubbleMenu: View {
|
||||
// FAB(하단 중앙)를 기준점으로 빛 무리와 버블들이 부채꼴로 퍼진다.
|
||||
ZStack {
|
||||
glow(radius: radius)
|
||||
// 모든 유리 요소를 한 컨테이너에 담아 한 패스로 렌더링 + 근접 시 액체 모핑
|
||||
GlassEffectContainer(spacing: glassBlendSpacing) {
|
||||
ZStack {
|
||||
if expanded {
|
||||
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
bubble(tab, index: index, count: tabs.count, radius: radius)
|
||||
}
|
||||
}
|
||||
fab
|
||||
// 모든 유리 요소를 한 컨테이너에 담아 한 패스로 렌더링 + 근접 시 액체 모핑.
|
||||
// iOS 18은 컨테이너가 없어 같은 클러스터를 그대로 그린다(모핑만 없음).
|
||||
// 26 API 직접 호출 금지 — LiquidGlassBall 주석의 컴파일러 우회 규칙 참고
|
||||
if #available(iOS 26.0, *) {
|
||||
GlassClusterContainer(spacing: glassBlendSpacing) {
|
||||
bubbleCluster(radius: radius)
|
||||
}
|
||||
} else {
|
||||
bubbleCluster(radius: radius)
|
||||
}
|
||||
}
|
||||
.frame(width: fabSize, height: fabSize)
|
||||
@ -196,6 +198,18 @@ struct GlassBubbleMenu: View {
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
/// FAB + 버블 클러스터 — 26의 GlassEffectContainer 안팎에서 같은 계층을 쓴다
|
||||
private func bubbleCluster(radius: CGFloat) -> some View {
|
||||
ZStack {
|
||||
if expanded {
|
||||
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
bubble(tab, index: index, count: tabs.count, radius: radius)
|
||||
}
|
||||
}
|
||||
fab
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 유리 구슬 버튼
|
||||
|
||||
/// 탭 하나를 리퀴드 글라스 구슬로 렌더링 — 아이콘과 이름이 한 구슬 안에 있어
|
||||
@ -230,15 +244,8 @@ struct GlassBubbleMenu: View {
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// 진짜 유리: 배경 굴절·빛 반사에 더해 .interactive가 눌림 시머·바운스까지 그려 준다.
|
||||
// 현재 탭은 초록 틴트 유리로 강조.
|
||||
.glassEffect(
|
||||
isCurrent
|
||||
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
|
||||
: .regular.interactive(),
|
||||
in: .circle
|
||||
)
|
||||
.glassEffectID(tab.rawValue, in: glassSpace)
|
||||
// 진짜 유리(26) 또는 머티리얼 원(18): 현재 탭은 양쪽 모두 초록으로 강조.
|
||||
.modifier(LiquidBallBackground(tinted: isCurrent, morphID: tab.rawValue, namespace: glassSpace))
|
||||
.offset(x: bloomed ? dx : 0, y: bloomed ? dy : 0)
|
||||
// 펼칠 때만 안쪽부터 차례로 맺히는 스태거 + 감쇠 낮은 스프링(오버슈트로 액체 출렁임).
|
||||
// 되돌아갈 땐 지연 없이 한 번에 FAB로 흡수된다.
|
||||
@ -266,8 +273,7 @@ struct GlassBubbleMenu: View {
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
.glassEffectID("fab", in: glassSpace)
|
||||
.modifier(LiquidBallBackground(tinted: false, morphID: "fab", namespace: glassSpace))
|
||||
// 펼치면 살짝 커져 '유리가 부풀며 방울을 내보내는' 기준점이 된다
|
||||
.scaleEffect(expanded ? 1.07 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: expanded)
|
||||
@ -322,3 +328,78 @@ struct GlassBubbleMenu: View {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 구슬 배경 (26 리퀴드 글라스 / 18 머티리얼 폴백)
|
||||
|
||||
/// 26 전용 컨테이너 래퍼 — LiquidGlassBall과 같은 이유로 선언을 @available로 격리
|
||||
@available(iOS 26.0, *)
|
||||
private struct GlassClusterContainer<Content: View>: View {
|
||||
let spacing: CGFloat
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
GlassEffectContainer(spacing: spacing) { content }
|
||||
}
|
||||
}
|
||||
|
||||
/// 유리 구슬 배경 — iOS 26: 시스템 리퀴드 글라스(.regular.interactive — 배경 굴절·빛 반사·
|
||||
/// 눌림 시머) + glassEffectID로 FAB와 한 덩어리 모핑. 현재 탭은 초록 틴트 유리.
|
||||
/// iOS 18 폴백: 머티리얼 블러 원 + 하이라이트 테두리 + 그림자 — 굴절·모핑만 없고
|
||||
/// 크기·배치·애니메이션은 26과 동일해 사용성 차이가 없다. 현재 탭은 초록 채움 원.
|
||||
/// 26 전용 유리 모디파이어 — 선언을 @available로 격리해 가용성 분기(하위 18 폴백)와
|
||||
/// glassEffect 제네릭이 뒤섞이지 않게 한다 (하한 18 Release 인라이너 크래시 조사 때
|
||||
/// 정리한 방어적 형태 — 실제 크래시 원인은 DiaryZoomCoordinator 쪽이었다, §6.7)
|
||||
@available(iOS 26.0, *)
|
||||
private struct LiquidGlassBall: ViewModifier {
|
||||
let tinted: Bool
|
||||
let morphID: String
|
||||
let namespace: Namespace.ID
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.glassEffect(
|
||||
tinted
|
||||
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
|
||||
: .regular.interactive(),
|
||||
in: .circle
|
||||
)
|
||||
.glassEffectID(morphID, in: namespace)
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 18 폴백 구슬 — 머티리얼 블러 원 + 하이라이트 테두리 + 그림자
|
||||
private struct MaterialBall: ViewModifier {
|
||||
let tinted: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
if tinted {
|
||||
Circle().fill(AppTheme.green.opacity(0.85))
|
||||
}
|
||||
Circle().strokeBorder(.white.opacity(0.28), lineWidth: 0.6)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.2), radius: 9, y: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiquidBallBackground: ViewModifier {
|
||||
let tinted: Bool
|
||||
let morphID: String
|
||||
let namespace: Namespace.ID
|
||||
|
||||
// 분기는 OS로 고정되어 런타임에 바뀌지 않으므로 AnyView 소거가
|
||||
// 애니메이션·모핑 정체성에 영향을 주지 않는다
|
||||
func body(content: Content) -> AnyView {
|
||||
if #available(iOS 26.0, *) {
|
||||
return AnyView(content.modifier(
|
||||
LiquidGlassBall(tinted: tinted, morphID: morphID, namespace: namespace)
|
||||
))
|
||||
} else {
|
||||
return AnyView(content.modifier(MaterialBall(tinted: tinted)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +189,7 @@ struct RecordFilterSheet: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
@ -352,7 +352,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.foregroundStyle(goal.color)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
29
myApp/HaruDanim/Shared/SymbolCompat.swift
Normal file
29
myApp/HaruDanim/Shared/SymbolCompat.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// SymbolCompat.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 사용자 지정 SF 심볼의 안전 렌더 (1.4 iOS 18 하위 지원).
|
||||
// 아이콘 카탈로그에는 상위 OS 전용 신형 심볼(예: SF 7의 broom.fill)이 들어 있어,
|
||||
// 26 기기에서 고른 심볼이 iCloud 동기화로 iOS 18 기기(또는 구형 watchOS)에 오면
|
||||
// Image(systemName:)이 아무것도 그리지 않는다. 사용자 데이터(행동·목표)에서 온
|
||||
// 심볼 이름은 반드시 이 경유지로 렌더할 것 — 코드 리터럴 심볼(전부 구형)은 비대상.
|
||||
// 워치 앱·워치 위젯 타깃에는 같은 헬퍼의 사본이 있다(WatchSymbolCompat —
|
||||
// 폴더 동기화 그룹 멤버십이 갈려 한 파일을 공유할 수 없음).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum SymbolCompat {
|
||||
/// 이 OS에 없는 심볼 이름을 안전한 폴백으로 바꾼다
|
||||
static func safe(_ name: String) -> String {
|
||||
UIImage(systemName: name) != nil ? name : "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
extension Image {
|
||||
/// 사용자 데이터의 심볼 렌더 전용 — 이 OS에 없는 이름은 물음표로 대치
|
||||
init(safeSymbol name: String) {
|
||||
self.init(systemName: SymbolCompat.safe(name))
|
||||
}
|
||||
}
|
||||
@ -90,7 +90,7 @@ struct ActionRunControl: ControlWidget {
|
||||
: value.isCount
|
||||
? String(localized: "탭해서 +1")
|
||||
: isOn ? String(localized: "측정 중") : String(localized: "시작"),
|
||||
systemImage: value.symbolName
|
||||
systemImage: SymbolCompat.safe(value.symbolName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +196,7 @@ struct ActionRunCellView: View {
|
||||
private var tileContent: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: cell.symbolName)
|
||||
Image(safeSymbol: cell.symbolName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Spacer()
|
||||
runningDot
|
||||
@ -219,7 +219,7 @@ struct ActionRunCellView: View {
|
||||
/// 가로형 (중형 2×2 / 대형 작은 셀 8개)
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: cell.symbolName)
|
||||
Image(safeSymbol: cell.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(cell.name)
|
||||
|
||||
@ -139,7 +139,7 @@ struct GoalBarsCellView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(goal.color)
|
||||
Text(goal.title)
|
||||
@ -173,7 +173,7 @@ struct QuestBarRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: quest.symbolName)
|
||||
Image(safeSymbol: quest.symbolName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(quest.color)
|
||||
.frame(width: 14)
|
||||
@ -596,7 +596,7 @@ struct QuestStatusWidgetView: View {
|
||||
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(goal.color)
|
||||
Text(goal.title)
|
||||
|
||||
@ -33,7 +33,7 @@ struct TrackingLiveActivity: Widget {
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: context.state.symbolName)
|
||||
Image(safeSymbol: context.state.symbolName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
@ -65,7 +65,7 @@ struct TrackingLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: context.state.symbolName)
|
||||
Image(safeSymbol: context.state.symbolName)
|
||||
.foregroundStyle(context.state.tintColor)
|
||||
} compactTrailing: {
|
||||
HStack(spacing: 2) {
|
||||
@ -80,7 +80,7 @@ struct TrackingLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
} minimal: {
|
||||
Image(systemName: context.state.symbolName)
|
||||
Image(safeSymbol: context.state.symbolName)
|
||||
.foregroundStyle(context.state.tintColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@ struct LockGoalWidgetView: View {
|
||||
switch entry.style {
|
||||
case .gauge:
|
||||
Gauge(value: min(max(entry.ratio, 0), 1)) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.widgetAccentable()
|
||||
} currentValueLabel: {
|
||||
Text("\(Int((entry.ratio * 100).rounded()))")
|
||||
@ -180,7 +180,7 @@ struct LockGoalWidgetView: View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
questDots(goal, size: 6)
|
||||
@ -192,7 +192,7 @@ struct LockGoalWidgetView: View {
|
||||
private func rectangularView(_ goal: GoalSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: goal.symbolName)
|
||||
Image(safeSymbol: goal.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(goal.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
|
||||
@ -234,7 +234,7 @@ struct NowRunningSmallView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Image(systemName: primary.symbolName)
|
||||
Image(safeSymbol: primary.symbolName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Spacer()
|
||||
Image(systemName: "record.circle")
|
||||
@ -285,7 +285,7 @@ struct NowRunningHeroView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: primary.symbolName)
|
||||
Image(safeSymbol: primary.symbolName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.frame(width: 46, height: 46)
|
||||
.background(
|
||||
@ -404,7 +404,7 @@ struct NowRunningRowView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.symbolName)
|
||||
Image(safeSymbol: item.symbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.frame(width: 30, height: 30)
|
||||
@ -458,7 +458,7 @@ struct LockScreenActivityView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: state.symbolName)
|
||||
Image(safeSymbol: state.symbolName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
@ -248,7 +248,7 @@ struct WidgetEmptyView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: symbolName)
|
||||
Image(safeSymbol: symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
Text(message)
|
||||
@ -297,7 +297,7 @@ struct QuestRingView: View {
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: snapshot.symbolName)
|
||||
Image(safeSymbol: snapshot.symbolName)
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.foregroundStyle(snapshot.color)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user