swiftswiftui

Swift: Robust way to ensure text fits in widget


I currently have a fairly hacky solution to ensuring (albeit, not well) that quote text from my API calls fit within my widget. I'm wondering if there is a more robust way to ensure that I'm only displaying quotes that'll fit within the user's widget, such that the text, author, like button, and like count all fit.

I currently call this getRandomQuoteByClassification method to fetch a quote from my API, until it finds a quote that'll fit. The values I use are entirely arbitrary, and I've had to play around with them.

                    while isQuoteTooLong(text: quote.text, context: context, author: quote.author) {
                        // Fetch a new quote
                        getRandomQuoteByClassification(classification: data.getQuoteCategory().lowercased()) { newQuote, _ in
                            if let newQuote = newQuote {
                                quote = newQuote
                            }
                        }
                    }

The current (bad) way I'm checking for whether the quote fits in the widget is this:

    // Helper function to check if a quote is too long
    private func isQuoteTooLong(text: String, context: Context, author: String?) -> Bool {
        let maxWidth: CGFloat = {
            switch context.family {
            case .systemSmall:
                return 20
            case .systemMedium:
                return 200
            case .systemLarge:
                return 300
            case .systemExtraLarge:
                return 400
            case .accessoryCircular:
                return 120
            case .accessoryRectangular:
                return 180
            case .accessoryInline:
                return 100
            @unknown default:
                return 100
            }
        }()
        
        var maxHeight: CGFloat = {
            switch context.family {
            case .systemSmall:
                return 20
            case .systemMedium:
                return 40
            case .systemLarge:
                return 100
            case .systemExtraLarge:
                return 200
            case .accessoryCircular:
                return 120
            case .accessoryRectangular:
                return 180
            case .accessoryInline:
                return 60
            @unknown default:
                return 20
            }
        }()
        
        // Check if the author is going to take up 2 lines and adjust the maxHeight accordingly
        
        // adjusted
        if let author = author, (!author.isEmpty && author != "Unknown Author" && author != "" && author != "NULL") {
            let authorFont = UIFont.systemFont(ofSize: 14) // Use an appropriate font size for the author
            let authorBoundingBox = author.boundingRect(
                with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
                options: [.usesLineFragmentOrigin],
                attributes: [NSAttributedString.Key.font: authorFont],
                context: nil
            )
            
            if authorBoundingBox.height > maxHeight / 2 {
                maxHeight = maxHeight * 0.85 // Adjust the factor as needed
            }
        }
        
        let font = UIFont.systemFont(ofSize: 16) // Use an appropriate font size
        let boundingBox = text.boundingRect(
            with: CGSize(width: maxWidth, height: maxHeight),
            options: [.usesLineFragmentOrigin],
            attributes: [NSAttributedString.Key.font: font],
            context: nil
        )
        
        // Check if the quote has an author
        
        // adjusted
        if let author = author, (!author.isEmpty && author != "Unknown Author" && author != "" && author != "NULL") {
            return boundingBox.height > maxHeight
        } else {
            // Allow the quote to be 5% longer when there is no author
            let maxAllowedHeight = maxHeight * 1.05
            return boundingBox.height > maxAllowedHeight
        }
    }

As you can read in the following code for the View, only widgets with a size greater than .systemSmall will get the like button and count (intended):

                    Text("\(widgetQuote.text)")
                        .font(Font.custom(availableFonts[data.selectedFontIndex], size: 16)) // Use the selected font
                        .foregroundColor(colors[1]) // Use the second color for text color
                        .padding(.horizontal, 10)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
                    if family == .systemSmall {
                        if (widgetQuote.author != "Unknown Author" && widgetQuote.author != nil && widgetQuote.author != "" && widgetQuote.author != "NULL") {
                            Text("— \(widgetQuote.author ?? "")")
                                .font(Font.custom(availableFonts[data.selectedFontIndex], size: 14)) // Use the selected font for author text
                                .foregroundColor(colors[2]) // Use the third color for author text color
                                .padding(.horizontal, 10)
                        }
                    } else {
                        HStack {
                            if (widgetQuote.author != "Unknown Author" && widgetQuote.author != nil && widgetQuote.author != "" && widgetQuote.author != "NULL") {
                                Text("— \(widgetQuote.author ?? "")")
                                    .foregroundColor(colors[2]) // Use the third color for author text color
                                    .padding(.horizontal, 10)
                            }
                            
                            if #available(iOSApplicationExtension 17.0, *) {
                                Button(intent: LikeQuoteIntent()) {
                                    Image(systemName: isLiked ? "heart.fill" : "heart")
                                        .foregroundStyle(colors[2])
                                }
                            } else {
                                Image(systemName: isLiked ? "heart.fill" : "heart")
                                    .foregroundStyle(colors[2])
                            }
                            
                            Text("\(widgetQuote.likes ?? 69)")
                                .foregroundColor(colors[2])
                        }
                        .padding(.horizontal, 5)
                        .font(Font.custom(availableFonts[data.selectedFontIndex], size: 14))
                    }

So, how can I make my isQuoteTooLong method more robust? Currently, it's very prone to mishaps, as such (there should never be quotes too long, because then they're cut off and ellipses show):

enter image description here


Solution

  • You can try font and minimumScaleFactor to achieve similar result.

    You can check this example to see that the text size is dynamic depending on the length of the text and size of the parent view.

        VStack(alignment: .leading) {
            Text(text)
                .font(.system(size: 500))
                .minimumScaleFactor(0.01)
        }
        .frame(width: 300, height: 500)
        .background(.red)
        .onTapGesture {
            text = "This is my long text which is very long"
        }
    

    This is the main code that you have to use inside any view. Other things are just to show example.

    Text(text)
                        .font(.system(size: 500))
                        .minimumScaleFactor(0.01)
    

    You can play around with the text size as per your needs. Make sure to keep it on larger size and the text will scale accordingly.