443 lines
16 KiB
Swift
443 lines
16 KiB
Swift
//
|
|
// FontUtils.swift
|
|
// TheNoiseClock
|
|
//
|
|
// Created by Matt Bruce on 9/7/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// Font sizing and typography utilities
|
|
enum FontUtils {
|
|
|
|
/// Calculate dynamic font size based on container dimensions
|
|
/// - Parameters:
|
|
/// - containerWidth: Container width
|
|
/// - containerHeight: Container height
|
|
/// - Returns: Calculated font size
|
|
static func dynamicFontSize(containerWidth: CGFloat, containerHeight: CGFloat) -> CGFloat {
|
|
let shortest = min(containerWidth, containerHeight)
|
|
return min(shortest * 0.28, AppConstants.Defaults.maxFontSize)
|
|
}
|
|
|
|
/// Calculate optimal font size that maximizes space usage
|
|
/// - Parameters:
|
|
/// - containerWidth: Available container width
|
|
/// - containerHeight: Available container height
|
|
/// - isPortrait: Whether the device is in portrait orientation
|
|
/// - showSeconds: Whether seconds are displayed
|
|
/// - showAmPm: Whether AM/PM is displayed
|
|
/// - Returns: Optimal font size for maximum space utilization
|
|
static func optimalFontSize(
|
|
containerWidth: CGFloat,
|
|
containerHeight: CGFloat,
|
|
isPortrait: Bool,
|
|
showSeconds: Bool = false,
|
|
showAmPm: Bool = false
|
|
) -> CGFloat {
|
|
// Account for safe areas and padding
|
|
let safeInset = AppConstants.Defaults.safeInset
|
|
let availableWidth = max(1, containerWidth - safeInset * 2)
|
|
let availableHeight = max(1, containerHeight - safeInset * 2)
|
|
|
|
// Estimate text content requirements (for future use)
|
|
_ = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS
|
|
_ = showAmPm ? 2 : 0 // AM/PM
|
|
|
|
// Calculate optimal size based on orientation and content
|
|
let optimalSize: CGFloat
|
|
if isPortrait {
|
|
// In portrait, height is the limiting factor
|
|
// Account for separators and spacing
|
|
let separatorHeight = availableHeight * 0.08 // 8% for separators (reduced)
|
|
let contentHeight = availableHeight - separatorHeight
|
|
let estimatedLines = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM
|
|
let lineHeight = contentHeight / CGFloat(estimatedLines)
|
|
optimalSize = lineHeight * 0.85 // 85% of line height for actual text (increased)
|
|
} else {
|
|
// In landscape, be more aggressive with space usage
|
|
// Account for separators and spacing
|
|
let separatorWidth = availableWidth * 0.08 // 8% for separators (reduced)
|
|
let contentWidth = availableWidth - separatorWidth
|
|
let estimatedColumns = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM
|
|
let columnWidth = contentWidth / CGFloat(estimatedColumns)
|
|
optimalSize = columnWidth * 0.75 // 75% of column width for actual text (increased)
|
|
}
|
|
|
|
// Apply reasonable bounds
|
|
let minSize: CGFloat = 20
|
|
let maxSize: CGFloat = AppConstants.Defaults.maxFontSize
|
|
return max(minSize, min(optimalSize, maxSize))
|
|
}
|
|
|
|
/// Calculate font size that fills available space with scaling
|
|
/// - Parameters:
|
|
/// - containerWidth: Available container width
|
|
/// - containerHeight: Available container height
|
|
/// - textContent: The actual text content to measure
|
|
/// - isPortrait: Whether the device is in portrait orientation
|
|
/// - Returns: Font size that maximizes space usage
|
|
static func fillSpaceFontSize(
|
|
containerWidth: CGFloat,
|
|
containerHeight: CGFloat,
|
|
textContent: String,
|
|
isPortrait: Bool
|
|
) -> CGFloat {
|
|
let safeInset = AppConstants.Defaults.safeInset
|
|
let availableWidth = max(1, containerWidth - safeInset * 2)
|
|
let availableHeight = max(1, containerHeight - safeInset * 2)
|
|
|
|
// Start with a reasonable base size
|
|
let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15
|
|
|
|
// Binary search for optimal size
|
|
var low: CGFloat = 10
|
|
var high: CGFloat = AppConstants.Defaults.maxFontSize
|
|
var bestSize: CGFloat = baseSize
|
|
|
|
for _ in 0..<10 { // Limit iterations
|
|
let testSize = (low + high) / 2
|
|
let font = UIFont.systemFont(ofSize: testSize, weight: .bold)
|
|
let textSize = measureTextSize(text: textContent, font: font)
|
|
|
|
let fitsWidth = textSize.width <= availableWidth
|
|
let fitsHeight = textSize.height <= availableHeight
|
|
|
|
if fitsWidth && fitsHeight {
|
|
bestSize = testSize
|
|
low = testSize
|
|
} else {
|
|
high = testSize
|
|
}
|
|
}
|
|
|
|
return bestSize
|
|
}
|
|
|
|
/// Calculate AM/PM font size based on base font size
|
|
/// - Parameter baseFontSize: Base font size
|
|
/// - Returns: AM/PM font size (20% of base)
|
|
static func ampmFontSize(baseFontSize: CGFloat) -> CGFloat {
|
|
return baseFontSize * 0.20
|
|
}
|
|
|
|
// MARK: - Font Customization
|
|
|
|
/// Convert font family string to Font
|
|
/// - Parameter family: Font family name
|
|
/// - Returns: SwiftUI Font
|
|
static func fontFamily(_ family: String) -> Font {
|
|
switch family {
|
|
case "San Francisco":
|
|
return .system(.body, design: .default)
|
|
case "System":
|
|
return .system(.body, design: .default)
|
|
case "Monaco":
|
|
return .system(.body, design: .monospaced)
|
|
case "Courier":
|
|
return .system(.body, design: .monospaced)
|
|
default:
|
|
return .system(.body, design: .default)
|
|
}
|
|
}
|
|
|
|
/// Convert font weight string to Font.Weight
|
|
/// - Parameter weight: Font weight name
|
|
/// - Returns: Font.Weight
|
|
static func fontWeight(_ weight: String) -> Font.Weight {
|
|
switch weight {
|
|
case "Ultra Light":
|
|
return .ultraLight
|
|
case "Thin":
|
|
return .thin
|
|
case "Light":
|
|
return .light
|
|
case "Regular":
|
|
return .regular
|
|
case "Medium":
|
|
return .medium
|
|
case "Semibold":
|
|
return .semibold
|
|
case "Bold":
|
|
return .bold
|
|
case "Heavy":
|
|
return .heavy
|
|
case "Black":
|
|
return .black
|
|
default:
|
|
return .bold
|
|
}
|
|
}
|
|
|
|
/// Convert font design string to Font.Design
|
|
/// - Parameter design: Font design name
|
|
/// - Returns: Font.Design
|
|
static func fontDesign(_ design: String) -> Font.Design {
|
|
switch design {
|
|
case "Default":
|
|
return .default
|
|
case "Serif":
|
|
return .serif
|
|
case "Rounded":
|
|
return .rounded
|
|
case "Monospaced":
|
|
return .monospaced
|
|
default:
|
|
return .rounded
|
|
}
|
|
}
|
|
|
|
/// Create a custom font with specified parameters
|
|
/// - Parameters:
|
|
/// - size: Font size
|
|
/// - family: Font family name
|
|
/// - weight: Font weight name
|
|
/// - design: Font design name
|
|
/// - Returns: SwiftUI Font
|
|
static func customFont(
|
|
size: CGFloat,
|
|
family: String,
|
|
weight: String,
|
|
design: String
|
|
) -> Font {
|
|
return .system(
|
|
size: size,
|
|
weight: fontWeight(weight),
|
|
design: fontDesign(design)
|
|
)
|
|
}
|
|
|
|
/// Calculate the maximum width for any two-digit combination
|
|
/// - Parameters:
|
|
/// - font: The font to measure with
|
|
/// - fontSize: The font size
|
|
/// - Returns: Maximum width for any two-digit combination
|
|
static func maxTwoDigitWidth(font: UIFont, fontSize: CGFloat) -> CGFloat {
|
|
let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
var maxWidth: CGFloat = 0
|
|
|
|
// Test all possible two-digit combinations to find the widest
|
|
for firstDigit in digits {
|
|
for secondDigit in digits {
|
|
let combination = firstDigit + secondDigit
|
|
let width = measureTextSize(text: combination, font: font).width
|
|
maxWidth = max(maxWidth, width)
|
|
}
|
|
}
|
|
|
|
return maxWidth
|
|
}
|
|
|
|
/// Get weight multiplier for visual consistency with font weight
|
|
/// - Parameter weight: Font weight name
|
|
/// - Returns: Multiplier for dot size (0.7 to 1.3)
|
|
static func weightMultiplier(for weight: String) -> CGFloat {
|
|
switch weight {
|
|
case "Ultra Light":
|
|
return 0.7
|
|
case "Thin":
|
|
return 0.75
|
|
case "Light":
|
|
return 0.8
|
|
case "Regular":
|
|
return 0.85
|
|
case "Medium":
|
|
return 0.9
|
|
case "Semibold":
|
|
return 1.0
|
|
case "Bold":
|
|
return 1.1
|
|
case "Heavy":
|
|
return 1.2
|
|
case "Black":
|
|
return 1.3
|
|
default:
|
|
return 1.0
|
|
}
|
|
}
|
|
|
|
/// Create a UIFont with specified parameters for measurements
|
|
/// - Parameters:
|
|
/// - size: Font size
|
|
/// - family: Font family name
|
|
/// - weight: Font weight name
|
|
/// - design: Font design name
|
|
/// - Returns: UIFont
|
|
static func customUIFont(
|
|
size: CGFloat,
|
|
family: String,
|
|
weight: String,
|
|
design: String
|
|
) -> UIFont {
|
|
let uiWeight: UIFont.Weight
|
|
switch weight {
|
|
case "Ultra Light":
|
|
uiWeight = .ultraLight
|
|
case "Thin":
|
|
uiWeight = .thin
|
|
case "Light":
|
|
uiWeight = .light
|
|
case "Regular":
|
|
uiWeight = .regular
|
|
case "Medium":
|
|
uiWeight = .medium
|
|
case "Semibold":
|
|
uiWeight = .semibold
|
|
case "Bold":
|
|
uiWeight = .bold
|
|
case "Heavy":
|
|
uiWeight = .heavy
|
|
case "Black":
|
|
uiWeight = .black
|
|
default:
|
|
uiWeight = .bold
|
|
}
|
|
|
|
let uiDesign: UIFontDescriptor.SystemDesign
|
|
switch design {
|
|
case "Serif":
|
|
uiDesign = .serif
|
|
case "Rounded":
|
|
uiDesign = .rounded
|
|
case "Monospaced":
|
|
uiDesign = .monospaced
|
|
default:
|
|
uiDesign = .default
|
|
}
|
|
|
|
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
|
.withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
|
|
|
return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), size: size)
|
|
}
|
|
|
|
/// Measure text size with given font
|
|
/// - Parameters:
|
|
/// - text: Text to measure
|
|
/// - font: Font to use for measurement
|
|
/// - Returns: Size of the text
|
|
static func measureTextSize(text: String, font: UIFont) -> CGSize {
|
|
let attributes = [NSAttributedString.Key.font: font]
|
|
return (text as NSString).size(withAttributes: attributes)
|
|
}
|
|
|
|
/// Get the maximum character width for digits to ensure consistent spacing
|
|
/// - Parameter font: The font to measure with
|
|
/// - Returns: The width of the widest digit character
|
|
static func maxDigitWidth(font: UIFont) -> CGFloat {
|
|
let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
var maxWidth: CGFloat = 0
|
|
|
|
for digit in digits {
|
|
let size = measureTextSize(text: digit, font: font)
|
|
maxWidth = max(maxWidth, size.width)
|
|
}
|
|
|
|
return maxWidth
|
|
}
|
|
|
|
/// Calculate fixed-width layout size for time display to prevent jumping
|
|
/// - Parameters:
|
|
/// - font: The font to use for measurements
|
|
/// - showSeconds: Whether seconds are displayed
|
|
/// - showAmPm: Whether AM/PM is displayed
|
|
/// - isPortrait: Whether the device is in portrait orientation
|
|
/// - Returns: Total width and height needed for the layout
|
|
static func fixedWidthLayoutSize(
|
|
font: UIFont,
|
|
showSeconds: Bool,
|
|
showAmPm: Bool,
|
|
isPortrait: Bool
|
|
) -> (width: CGFloat, height: CGFloat) {
|
|
let digitWidth = maxDigitWidth(font: font)
|
|
let digitHeight = measureTextSize(text: "8", font: font).height // Use 8 as reference height
|
|
|
|
// Calculate separator sizes
|
|
let dotDiameter = font.pointSize * 0.20
|
|
let hSpacing = font.pointSize * 0.18
|
|
let vSpacing = font.pointSize * 0.22
|
|
|
|
if isPortrait {
|
|
// Portrait: vertical layout
|
|
let totalWidth = digitWidth * 2 // Two digits per row
|
|
let separatorHeight = dotDiameter * 2 + vSpacing
|
|
let contentHeight = digitHeight * (showSeconds ? 3 : 2) + separatorHeight
|
|
return (totalWidth, contentHeight)
|
|
} else {
|
|
// Landscape: horizontal layout
|
|
let digitCount = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS
|
|
let separatorCount = showSeconds ? 2 : 1
|
|
let separatorWidth = dotDiameter * 2 + hSpacing
|
|
let totalWidth = digitWidth * CGFloat(digitCount) + separatorWidth * CGFloat(separatorCount)
|
|
let totalHeight = digitHeight
|
|
return (totalWidth, totalHeight)
|
|
}
|
|
}
|
|
|
|
/// Calculate responsive font size for time picker components
|
|
/// - Parameters:
|
|
/// - containerWidth: Available container width
|
|
/// - containerHeight: Available container height
|
|
/// - isPortrait: Whether the device is in portrait orientation
|
|
/// - Returns: Font size optimized for time picker
|
|
static func timePickerFontSize(
|
|
containerWidth: CGFloat,
|
|
containerHeight: CGFloat,
|
|
isPortrait: Bool
|
|
) -> CGFloat {
|
|
let safeInset = AppConstants.Defaults.safeInset
|
|
let availableWidth = max(1, containerWidth - safeInset * 2)
|
|
let availableHeight = max(1, containerHeight - safeInset * 2)
|
|
|
|
// For time picker, we want larger, more readable fonts
|
|
let baseSize = isPortrait ? availableHeight * 0.15 : availableWidth * 0.08
|
|
|
|
// Apply bounds with higher minimum for readability
|
|
let minSize: CGFloat = 24
|
|
let maxSize: CGFloat = 72
|
|
return max(minSize, min(baseSize, maxSize))
|
|
}
|
|
|
|
/// Calculate maximum font size for stretched mode that uses visible space without overflow
|
|
/// - Parameters:
|
|
/// - containerWidth: Available container width
|
|
/// - containerHeight: Available container height
|
|
/// - isPortrait: Whether the device is in portrait orientation
|
|
/// - showSeconds: Whether seconds are displayed
|
|
/// - showAmPm: Whether AM/PM is displayed
|
|
/// - Returns: Maximum font size that fits within visible space
|
|
static func maximumStretchedFontSize(
|
|
containerWidth: CGFloat,
|
|
containerHeight: CGFloat,
|
|
isPortrait: Bool,
|
|
showSeconds: Bool = false,
|
|
showAmPm: Bool = false
|
|
) -> CGFloat {
|
|
// Use reasonable safe areas
|
|
let safeInset = AppConstants.Defaults.safeInset
|
|
let availableWidth = max(1, containerWidth - safeInset * 2)
|
|
let availableHeight = max(1, containerHeight - safeInset * 2)
|
|
|
|
// Calculate optimal size with reasonable space usage
|
|
let optimalSize: CGFloat
|
|
if isPortrait {
|
|
// In portrait, use most of the available height
|
|
let contentHeight = availableHeight * 0.85 // Use 85% of available height
|
|
let estimatedLines = showSeconds ? 3 : 2
|
|
let lineHeight = contentHeight / CGFloat(estimatedLines)
|
|
optimalSize = lineHeight * 0.8 // Use 80% of line height
|
|
} else {
|
|
// In landscape, use most of the available width
|
|
let contentWidth = availableWidth * 0.85 // Use 85% of available width
|
|
let estimatedColumns = showSeconds ? 3 : 2
|
|
let columnWidth = contentWidth / CGFloat(estimatedColumns)
|
|
optimalSize = columnWidth * 0.7 // Use 70% of column width
|
|
}
|
|
|
|
// Apply reasonable bounds
|
|
let minSize: CGFloat = 20
|
|
let maxSize: CGFloat = AppConstants.Defaults.maxFontSize
|
|
return max(minSize, min(optimalSize, maxSize))
|
|
}
|
|
}
|