Home > front end >  Why changing environment's locale does not work?
Why changing environment's locale does not work?

Time:01-02

I am trying to localise the Widget. Setting .environment(\.locale, Locale(identifier: "ja")) does not work for most of the code:

It only works for:

  • Text(entry.date, style: .date)

but does NOT work for:

  • Text(Date.now.advanced(by: 60*60*12), style: .relative) and
  • if {} else {} statement

It should display something like:

こんにちは世界!
2022年12月31日
11時間, 57分

Instead it is displaying this:

enter image description here

struct SuperWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        WidgetView()
            .environment(\.locale, Locale(identifier: "ja"))
    }
    
    var languageCode: String? { Locale.autoupdatingCurrent.language.languageCode?.identifier }
    var localeCode: String? { Locale.autoupdatingCurrent.identifier }
    var isJapaneseLanguage: Bool { languageCode == "ja" }
    var isJapaneseLocale: Bool { localeCode == "ja_JP" }
        
    @ViewBuilder
    func WidgetView() -> some View {
        VStack {
            if isJapaneseLocale || isJapaneseLanguage {
                Text("こんにちは世界!")
            } else {
                Text("Hello, world!")
            }
            VStack {
                Text(entry.date, style: .date)
                Text(Date.now.advanced(by: 60*60*12), style: .relative)
            }
        }
    }
}

CodePudding user response:

I've written a workaround for the issue in my comment. It's kinda elaborate, but it seems to work.

It also is apparent that the Text view does not use a RelativeDateTimeFormatter internally.

Note, that the original Text facilitates "life updates", which requires a Model to implement this behaviour in the custom view. It uses a timer and a RelativeDateTimeFormatter to accomplish this.

Update In order to make it clear, this code only demonstrates the issue with the broken localisation of the SwiftUI Text view when using a Date as parameter and relative as style. It can not be just copy&pasted into your Widget.

Below you can experiment with the code:

import SwiftUI
import Combine

struct RelativeDateText: View {
    private let date: Date
    private let unitsStyle: RelativeDateTimeFormatter.UnitsStyle

    init(
        from date: Date,
        unitsStyle: RelativeDateTimeFormatter.UnitsStyle
    ) {
        self.date = date
        self.unitsStyle = unitsStyle
    }

    var body: some View {
        EnvironmentReader(\.locale) { locale in
            _RelativeDateText(
                from: date,
                unitsStyle: unitsStyle,
                locale: locale
            )
        }
    }

    struct EnvironmentReader<Content: View, Env>: View {
        @Environment private var env: Env

        let content: (Env) -> Content

        init(
            _ keyPath: KeyPath<EnvironmentValues, Env>,
            content: @escaping (Env) -> Content
        ) {
            self._env = .init(keyPath)
            self.content = content
        }

        var body: some View {
            content(env)
        }
    }

    struct _RelativeDateText: View {
        @StateObject private var model: Model

        init(
            from date: Date,
            unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full,
            locale: Locale
        ) {
            self._model = .init(
                wrappedValue: .init(
                    date: date,
                    unitsStyle: unitsStyle,
                    locale: locale
                )
            )
        }

        var body: some View {
            Text(model.relativeDateString)
        }

        final class Model: ObservableObject {
            @Published var relativeDateString: String = ""

            private let relativeDateTimeFormatter: RelativeDateTimeFormatter
            private var cancellable: AnyCancellable!

            init(
                date: Date,
                unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full,
                locale: Locale
            ) {
                let formatter = RelativeDateTimeFormatter()
                formatter.unitsStyle = .abbreviated
                formatter.locale = locale
                self.relativeDateTimeFormatter = formatter

                cancellable = Timer.publish(every: 1, on: .main, in: .default)
                .autoconnect()
                .prepend(Date.now)
                .map { _ in
                    formatter.localizedString(for: date, relativeTo: Date.now)
                }
                .assign(to: \.relativeDateString, on: self)
            }
        }
    }

}

struct RelativeDateText_Previews: PreviewProvider {
    static var previews: some View {
        // Compare the original `Text` with custom `RelativeDateText`:
        VStack {
            Text(Date.now, style: .relative)
            RelativeDateText(
                from: .now,
                unitsStyle: .short
            )
        }
        .environment(\.locale, Locale(identifier: "ja_JP")) // <== "ja_JP" !!
    }
}

CodePudding user response:

Short Answer

I ran your code and assuming that Locale.autoupdatingCurrent gets a new locale from .environment(\.locale, entry.locale) is the issue.

@Environment(\.locale) var locale is the only way you can get SwiftUI's locale.

struct WidgetSample_WEntryView : View {
    var entry: Provider.Entry
    //This reads SwiftUI's locale not autoupdating
    @Environment(\.locale) var locale
    
    var languageCode: String? { locale.language.languageCode?.identifier
    }
    var localeCode: String? { locale.identifier }
    var isJapaneseLanguage: Bool { languageCode == "ja" }
    var isJapaneseLocale: Bool { localeCode == "ja_JP" }
    
    var body: some View {
        VStack{
            Text(Date.now.advanced(by: 60*60*12), style: .relative) //This does not localize, it is an Apple bug in a Main App as well.
            if isJapaneseLocale || isJapaneseLanguage {
                Text("こんにちは世界!")
            } else {
                Text("Hello, world!")
            }
            
        }
        //This will tell SwiftUI to use `locale`
        .environment(\.locale, .getCustomLocale(entry.languageCode))
    }
}

extension Locale{
    ///Gets locale of provided `languageCode` if `languageCode` == nil the `.current` locale will be returned
    static func getCustomLocale(_ languageCode: String?) -> Locale{
        guard let languageCode = languageCode else {
            return .current //Use device locale
        }
        let locale = Locale(identifier: languageCode)
        return locale //Use language code locale
    }
}

Long Answer

Since style: .relative doesn't work you have to manually implement the updates by providing the necessary entries in the getTimeline method and use a custom formatter than uses the custom locale

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline
        let currentDate = Date()
        for offset in 0..<60 {
            let entryDate = Calendar.current.date(byAdding: .second, value: offset, to: currentDate)!
            //All the important data must come from here.
            let entry = SimpleEntry(date: entryDate.advanced(by: 60*60*12), configuration: configuration, languageCode: "ja")
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

Then your SimpleEntry and View can look something like this.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
    /// nil uses device's localization
    let languageCode: String?

    // Workaround for Apple bug
    // Needs entries for every minute/second as desired and as allowed by the "Widget Budget"
    var localizedDate: String{
        let f = RelativeDateTimeFormatter()
        f.locale = .getCustomLocale(languageCode)
        f.unitsStyle = .abbreviated
        return f.string(for: date) ?? "unable to localize"
    }
}

struct WidgetSample_WEntryView : View {
    var entry: Provider.Entry
    @Environment(\.locale) var locale
    var body: some View {
        VStack{
            Text(entry.date, style: .relative) //This does not localize, it is an Apple bug in a Main App as well.
            
            Text(entry.localizedDate) //Needs entries for every minute/second as desired and as allowed by the "Widget Budget"
            if locale == Locale(identifier: "ja_JP"){
                Text("こんにちは世界!")
            }else{
                Text("Hello, world!")
            }
            
        }
        //This will tell SwiftUI to use `locale`
        .environment(\.locale, .getCustomLocale(entry.languageCode))
    }
}

Long Answer Part 2

Strings can also be localized by SwiftUI if you implement "Localization" in your app/extension. This would remove the if else logic where you check the locale for Text("Hello, world!").

Here are some basic steps to implement Localization

  1. Go to PROJECT > Localizations > > Select languages
  2. Create a "Localizable.strings" file
  3. Click on the "Localizable.strings" file and in the "File Inspector", "Localize" the file.
  4. Click on the "Localizable.strings" file and in the "File Inspector" look at "Localization" and click on the relevant languages.
  5. Click on the "Localizable.strings" file and in the "File Inspector" look at "Target Membership" and make sure the widget extension is selected.

enter image description here

The English file will look something like

/* 
  Localizable.strings (English)
  
*/
"Hello, world!" = "Hello, world!";

and the Japanese file will look something like

/* 
  Localizable.strings (Japanese)
  
*/
"Hello, world!" = "こんにちは世界!";

Then your View can be simplified to look something like this and you would get the English and Japanese versions of the key.

struct WidgetSample_WEntryView : View {
    var entry: Provider.Entry
    var body: some View {
        VStack{                
            Text(entry.localizedDate) //Needs entries for every minute/second as desired and as allowed by the "Widget Budget"
            
            Text("Hello, world!") //Text knows to look for the "Localizable.strings" file
        }
        //This will tell SwiftUI to use `locale`
        .environment(\.locale, .getCustomLocale(entry.languageCode))
    }
}
  • Related