Right-aligning Text(Date(), style: .timer) text in an iOS WidgetKit widget
Asked Answered
C

3

12

Here's an interesting quandary: I want to make a timer that "ticks" reliably but, also, renders symbols in predictable places so that I could, for instance, decorate the timer by adding a background. Because of WidgetKit limitations, I cannot reliably render my own text every second and have to rely on special views, such as Text(Date(), style: .timer). However, this view can render time as, both, XX:XX and X:XX depending on how much time is left, which would be OK, except, it also, both, takes the whole width of the container and aligns to the left, which makes the last :XX move depending on time left.

Here's an illustration:

enter image description here

And code that produced it:

struct MyWidgetEntryView : View {    
    var body: some View {
        VStack {
            Text(Date().addingTimeInterval(1000), style: .timer)
                .font(.body.monospacedDigit())
                .background(Color.red)
            
            Text(Date().addingTimeInterval(100), style: .timer)
                .background(Color.green)
                .font(.body.monospacedDigit())
        }
    }
}

Question: is there a way to make a reliably updating time display in a WidgetKit widget in such a way that symbols for minutes and seconds are always rendered in the same places and not move depending on time left?

I can't figure it out, please help me!

–Baglan

Corollary answered 7/8, 2021 at 12:40 Comment(3)
Just had a hacky idea: add 10000 hours to the time (Text(Date().addingTimeInterval(100 + 3600 * 10000), style: .timer)), this way, unless the number of hours is always going to be less than a 10000 hours, time would be rendered as: 1XXXX:XX:XX, resulting in predictable digit locations!Corollary
Can’t you do VStack(alignment: .trailing)?Dimitris
@JoakimDanielson that's the first thing I tried, unfortunately that didn't work because, in context of a widget, unlike, say, Text("Hello"), Text(Date(), style: .timer) has a width of the container – you can see that if you look at the width of the background color fill in the sample image, effectively, it has .frame(maxWidth: .infinity, alignment: .leading).Corollary
D
17

Set the multi-line text alignment:

Text(Date(), style: .timer)
    .multilineTextAlignment(.trailing)

It’s not multiple lines of text but it works!

Doubloon answered 9/8, 2021 at 16:28 Comment(1)
Nice find! Probably because this implicitly uses the .timer style, but setting this for the countdown style also works (which also solves my problem displaying a timer in a Live Activity): Text(timerInterval: context.attributes.startTime...context.attributes.endTime, countsDown: true) Notepaper
C
3

Still looking for a better answer, but here's a "proof of concept" hack to achieve my goal:

struct _T1WidgetEntryView : View {
    
    struct SizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }
    
    @State private var digitSize: CGSize = .zero
    @State private var semicolonSize: CGSize = .zero
    
    var body: some View {
        ZStack {
                        
            Text("0")
                .overlay(
                    GeometryReader { proxy in
                        Color.green
                            .preference(key: SizePreferenceKey.self, value: proxy.size)
                    }
                    .onPreferenceChange(SizePreferenceKey.self) { digitSize = $0 }
                )
                .hidden()
            
            Text(":")
                .overlay(
                    GeometryReader { proxy in
                        Color.green
                            .preference(key: SizePreferenceKey.self, value: proxy.size)
                    }
                    .onPreferenceChange(SizePreferenceKey.self) { semicolonSize = $0 }
                )
                .hidden()
            
            
            Color.clear
                .frame(width: digitSize.width * 4 + semicolonSize.width, height: digitSize.width * 4 + semicolonSize.width)
                .overlay(
                    Text(Date().addingTimeInterval(100 + 3600 * 200), style: .timer)
                        .frame(width: digitSize.width * 7 + semicolonSize.width * 2)
                    ,
                    alignment: .trailing
                )
                .clipped()
            
        }
        .font(.body.monospacedDigit())
    }

}

And the result is:

Sample image

This code assumes that all the digits are the same width (hence the .monospacedDigit() font modifier).

Here's what it does:

  1. Calculates the sizes of a digit symbol and the semicolon;
  2. "Normalizes" the time string by adding 200 hours to ensure the XXX:XX:XX formatting;
  3. Sets the size of the text to accommodate strings formatted as XXX:XX:XX;
  4. Sets the size of the container to accommodate strings formatted as XX:XX;
  5. Aligns the text .trailing in an overlay;
  6. Clips the whole thing to the size of the container.

Again, if there is a better solution, I'd love to learn about it!

–Baglan

Corollary answered 7/8, 2021 at 15:10 Comment(2)
Indeed, hacky, but it's a great one. Did you find any better way to to do this by any chance?Subatomic
If I recall the context for this question correctly, I'd say no, there is still no better way to do it.Corollary
N
2

I would just add to the amazing Adam's answer:

.monospacedDigit()

This way we avoid all the numbers moving every second that the last digit is a different size.

So it would be something like:

Text(Date(), style: .timer)
.monospacedDigit()
.multilineTextAlignment(.trailing)
Necessarily answered 26/11, 2023 at 8:0 Comment(1)
Thank you for your addition. That was, indeed, something I had done back when the question was asked. Hmm, looking at the code, seems line I had used the .monospaceDigit() although in a rather antiquated way :).Corollary

© 2022 - 2024 — McMap. All rights reserved.