Upgrade to Pro — share decks privately, control downloads, hide ads and more …

アプリ内にテーマ機能を実装する / pixiv-app-talk-202604-theme-f...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

アプリ内にテーマ機能を実装する / pixiv-app-talk-202604-theme-feature

2026/04/23(木) pixiv App Talkで発表した「アプリ内にテーマ機能を実装する」の資料です

Avatar for Atsuya Sato

Atsuya Sato

April 22, 2026

More Decks by Atsuya Sato

Other Decks in Programming

Transcript

  1. atsuyan / 𝕏 : @n_atmark • 2D Section / Pastela։ൃTeam

    iOSΤϯδχΞ • 2024೥2݄த్ೖࣾ • PastelaͷiPad޲͚ΞϓϦέʔγϣϯ։ൃΛ୲౰
  2. EnvironmentValuesͱͯ͠ςʔϚΛ؅ཧ͢Δ public enum Theme { case system case yumekawaPurple case

    yumekawaPink case yumekawaBlue } public extension Theme { var primaryColor: Color { switch self { case .system: return Color(.systemPrimary) case .yumekawaPurple: return Color(.yumekawaPurplePrimary) case .yumekawaPink: return Color(.yumekawaPinkPrimary) case .yumekawaBlue: return Color(.yumekawaBluePrimary) } } var backgroundColor: Color { ... } } import SwiftUI public extension EnvironmentValues { @Entry var theme: Theme = ThemeClient.currentTheme }
  3. EnvironmentValuesͱͯ͠ςʔϚΛ؅ཧ͢Δ public enum Theme { case system case yumekawaPurple case

    yumekawaPink case yumekawaBlue } public extension Theme { var primaryColor: Color { switch self { case .system: return Color(.systemPrimary) case .yumekawaPurple: return Color(.yumekawaPurplePrimary) case .yumekawaPink: return Color(.yumekawaPinkPrimary) case .yumekawaBlue: return Color(.yumekawaBluePrimary) } } var backgroundColor: Color { ... } } struct ContentView: View { @Environment(\.theme) private var theme var body: some View { VStack { Text("Hello, world!") .foregroundStyle(theme.primaryColor) } .padding() } } import SwiftUI public extension EnvironmentValues { @Entry var theme: Theme = ThemeClient.currentTheme }
  4. ςʔϚͷมߋΛ௨஌͢Δ @main struct SampleApp: App { @State private var theme:

    Theme = ThemeClient.currentTheme var body: some Scene { WindowGroup { ContentView() .environment(\.theme, theme) .task { for await newTheme in ThemeClient.themeChanged { theme = newTheme } } } } }
  5. ςʔϚͷมߋΛ௨஌͢Δ public struct ThemeClient { public static var currentTheme: Theme

    = .system { didSet { themeChangedSubject.send(currentTheme) } } public static let themeChangedSubject = PassthroughSubject<Theme, Never>() public static var themeChanged: AsyncStream<Theme> { return .init { continuation in let cancellable = themeChangedSubject.sink { value in continuation.yield(value) } continuation.onTermination = { _ in cancellable.cancel() } } } } @main struct SampleApp: App { @State private var theme: Theme = ThemeClient.currentTheme var body: some Scene { WindowGroup { ContentView() .environment(\.theme, theme) .task { for await newTheme in ThemeClient.themeChanged { theme = newTheme } } } } }
  6. طଘͷ຋༁ͷ࢓૊ΈΛ࢖͍ͭͭ ςʔϚจݴΛ੾Γସ͑ΒΕΔΑ͏ʹ͢Δ Text(theme == .system ? R.string.localizable.commonOk() : R.string.yumekawaTheme.commonOk()) struct

    PastelaTheme { static func commonOk() -> String { return ThemeClient.currentTheme == .system ? R.string.localizable.commonOk() : R.string.yumekawaTheme.commonOk() } } 😢 ౎౓ςʔϚઃఆΛνΣοΫͯ͠จݴΛ෼ذ͢Δ࣮૷Λॻ͘ͷ͸ਏ͍ 😢 ͔ͤͬ͘R.swift͕ܕੜ੒ͯ͘͠ΕͯΔͷΛखͰ΋͏Ұ౓ఆٛ͢Δͷ΋ਏ͍
  7. R.generated.swiftͷग़ྗܗࣜʹண໨͢Δ public let R = _R(bundle: Bundle.module) public struct _R:

    Sendable { public let bundle: Foundation.Bundle public init(bundle: Foundation.Bundle) { self.bundle = bundle } public var string: string { .init(bundle: bundle, preferredLanguages: nil, locale: nil) } }
  8. R.generated.swiftͷग़ྗܗࣜʹண໨͢Δ public struct string: Sendable { public let bundle: Foundation.Bundle

    public let preferredLanguages: [String]? public let locale: Locale? public init(bundle: Foundation.Bundle, preferredLanguages: [String]? = nil, locale: Locale? = nil) { self.bundle = bundle self.preferredLanguages = preferredLanguages self.locale = locale } public var localizable: localizable { .init(source: .init(bundle: bundle, tableName: "Localizable", preferredLanguages: preferredLanguages, locale: locale)) } public var yumekawaTheme: yumekawaTheme { .init(source: .init(bundle: bundle, tableName: "YumekawaTheme", preferredLanguages: preferredLanguages, locale: locale)) } public func localizable(preferredLanguages: [String]) -> localizable { .init(source: .init(bundle: bundle, tableName: "Localizable", preferredLanguages: preferredLanguages, locale: locale)) } public func yumekawaTheme(preferredLanguages: [String]) -> yumekawaTheme { .init(source: .init(bundle: bundle, tableName: "YumekawaTheme", preferredLanguages: preferredLanguages, locale: locale)) } }
  9. R.generated.swiftͷग़ྗܗࣜʹண໨͢Δ public struct localizable: Sendable { public let source: RswiftResources.StringResource.Source

    public init(source: RswiftResources.StringResource.Source) { self.source = source } public var commonOk: RswiftResources.StringResource { .init(key: "common.ok", tableName: "Localizable", source: source, developmentValue: nil, comment: nil) } // { ... } } public struct yumekawaTheme: Sendable { public let source: RswiftResources.StringResource.Source public init(source: RswiftResources.StringResource.Source) { self.source = source } public var commonOk: RswiftResources.StringResource { .init(key: "common.ok", tableName: "YumekawaTheme", source: source, developmentValue: nil, comment: nil) } // { ... } }
  10. R.generated.swiftͷग़ྗܗࣜʹண໨͢Δ extension StringResource { public func callAsFunction() -> String {

    String(resource: self) } } extension String { public init(resource: StringResource) { self.init(key: resource.key, tableName: resource.tableName, source: resource.source, developmentValue: resource.developmentValue, locale: nil, arguments: []) } } extension String { init(key: StaticString, tableName: String, source: StringResource.Source, developmentValue: String?, locale overrideLocale: Locale?, arguments: [CVarArg]) { switch source { case let .hosting(bundle): // With fallback to developmentValue let format = NSLocalizedString(key.description, tableName: tableName, bundle: bundle, value: developmentValue ?? "", comment: "") self = String(format: format, locale: overrideLocale ?? Locale.current, arguments: arguments) case let .selected(bundle, locale): // Don't use developmentValue with selected bundle/locale let format = NSLocalizedString(key.description, tableName: tableName, bundle: bundle, value: "", comment: "") self = String(format: format, locale: overrideLocale ?? locale, arguments: arguments) case .none: self = key.description } } }
  11. StringResourceΛ֦ு͢Δ "error.limit-exceeded" = "%dจࣈҎ಺Ͱೖྗ͍ͯͩ͘͠͞"; "error.invalid-range" = "%dจࣈҎ্%dจࣈҎԼͰೖྗ͍ͯͩ͘͠͞"; public var errorLimitExceeded:

    RswiftResources.StringResource1<Int> { .init(key: "error.limit-exceeded", tableName: "Localizable", source: source, developmentValue: nil, comment: nil) } public var errorInvalidRange: RswiftResources.StringResource2<Int, Int> { .init(key: "error.limit-exceeded", tableName: "Localizable", source: source, developmentValue: nil, comment: nil) } R.string.localizable.errorLimitExceeded(upperLimit) R.string.localizable.errorInvalidRange(lowerLimit, upperLimit) R.generated.swift ར༻࣌
  12. StringResourceΛ֦ு͢Δ protocol StringResourceProtocol { var key: StaticString { get }

    var developmentValue: String? { get } var comment: StaticString? { get } init(key: StaticString, tableName: String, source: RswiftResources.StringResource.Source, developmentValue: String?, comment: StaticString?) } extension RswiftResources.StringResource: StringResourceProtocol {} extension RswiftResources.StringResource1: StringResourceProtocol {} extension RswiftResources.StringResource2: StringResourceProtocol {} // ...
  13. StringResourceΛ֦ு͢Δ public extension RswiftResources.StringResource { var theme: RswiftResources.StringResource { ThemeSelector.overrideTheme(resource:

    self) } } public extension RswiftResources.StringResource1 { var theme: RswiftResources.StringResource1<Arg1> { ThemeSelector.overrideTheme(resource: self) } } public extension RswiftResources.StringResource2 { var theme: RswiftResources.StringResource2<Arg1, Arg2> { ThemeSelector.overrideTheme(resource: self) } } // ... protocol StringResourceProtocol { var key: StaticString { get } var developmentValue: String? { get } var comment: StaticString? { get } init(key: StaticString, tableName: String, source: RswiftResources.StringResource.Source, developmentValue: String?, comment: StaticString?) } extension RswiftResources.StringResource: StringResourceProtocol {} extension RswiftResources.StringResource1: StringResourceProtocol {} extension RswiftResources.StringResource2: StringResourceProtocol {} // ...
  14. StringResourceΛ֦ு͢Δ enum ThemeSelector { static func overrideTheme<T: StringResourceProtocol>(resource: T) ->

    T { if ThemeClient.currentTheme != .system { return overrideThemeIfNeeded(resource: resource, tableName: "YumekawaTheme", source: R.string.yumekawaTheme.source) } return resource } private static func overrideThemeIfNeeded<T: StringResourceProtocol>(resource: T, tableName: String, source: RswiftResources.StringResource.Source) -> T { let format = NSLocalizedString(resource.key.description, tableName: tableName, bundle: source.bundle ?? .module, value: "", comment: "") // Ωʔ্͕ॻ͖ઌςʔϚͷstringsʹଘࡏ͠ͳ͍৔߹͸Ωʔͦͷ΋ͷ͕ฦ٫͞ΕΔͨΊɺͦͷ৔߹͸ݩͷStringResourceΛͦͷ··ར༻͢Δ if format == resource.key.description { return resource } else { // ্ॻ͖ઌςʔϚʹΩʔ͕ଘࡏ͢Δ৔߹͸ɺtableNameΛஔ͖׵͑ͨStringResourceΛ࡞੒্ͯ͠ॻ͖͢Δ return T(key: resource.key, tableName: tableName, source: source, developmentValue: resource.developmentValue, comment: resource.comment) } } }
  15. StringResourceΛ֦ு͢Δ Text(R.string.localizable.panelLayer.theme()) // (ςʔϚઃఆதͷ৔߹) ࣗಈͰςʔϚจݴ͕࠾༻͞ΕΔ // Ε͍΍ʔ Text(R.string.localizable.errorLogin.theme()) // ςʔϚจݴ͕ͳ͍৔߹͸σϑΥϧτͷจݴ͕දࣔ͞ΕΔ

    // ϩάΠϯʹࣦഊ͠·ͨ͠ Text(R.string.localizable.commonOk()) // ن໿ը໘ͳͲςʔϚͷੈք؍͔Β֎ΕΔՕॴ΋ theme() Λ෇͚ͳ͚Ε͹੾Γସ͑ແ͠ // OK Localizable.strings YumekawaTheme.strings "common.ok" = "OK"; "common.ok" = “OK♡"; "error.login" = "ϩάΠϯʹࣦഊ͠·ͨ͠"; "panel.layer" = "ϨΠϠʔ"; "panel.layer" = “Ε͍΍ʔ";
  16. ࠾༻͠ͳ͔ͬͨํ๏ extension LocalizableStrings { private static func customBundle(tag: String) ->

    Bundle? { let customLocale = "\(Locale.current.identifier)-x-\(tag)" guard let path = Bundle.module.path(forResource: customLocale, ofType: "lproj"), let bundle = Bundle(path: path) else { return nil } return bundle } private static var languageBundle: Bundle { guard let path = Bundle.module.path(forResource: Locale.current.identifier, ofType: "lproj"), let bundle = Bundle(path: path) else { // Locale.current.identifierʹ֘౰͢Δlproj͕ͳ͍৔߹͸Bundle.moduleΛฦ٫ return Bundle.module } return bundle } public static var bundle: Bundle { // ػೳ༗ޮͳΒyumekawaBundleΛར༻ if ThemeClient.currentTheme != .system, let yumekawaBundle = customBundle(tag: "yumekawa") { return yumekawaBundle } else { return languageBundle } } }
  17. ࠾༻͠ͳ͔ͬͨํ๏ extension _R { public var pastelaString: string { .init(bundle:

    LocalizableStrings.bundle, preferredLanguages: nil, locale: nil) } } R.pastelaString.localizable.panelLayer() R.pastelaString.localizable.errorInvalidRange(lowerLimit, upperLimit)