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