Alex
Alex

Reputation: 766

Wrap SwiftUI Text into two columns

I am building a widget that will hold some text that is a list of short words and phrases. Suppose the text content is

Foo
Bar
Baz
Bewildered
Add another
And one more

The current result is this:

Widget Screenshot

Because it's a list of short items it would work best if it could wrap into two columns, like below

Foo           Add another
Bar           And one more
Baz
Bewildered

Here's the current simple code (with font and spacings removed):

struct WidgetEntryView : View {
    
  var entry: Provider.Entry
  
  var body: some View {
    ZStack {
      
      Color(entry.color)
      
      VStack(alignment: .leading) {
        Text(entry.name)
        
        Text("Updated in 6 hours")
        
        Text(entry.content)
      }
    }
  }
}

I found this guide to tell me whether or not the text is truncated, but what I need is to know what text has been truncated so that I can add another Text view to the right with the remaining characters. Or ideally use some native method to continue the text between two text views.

Upvotes: 2

Views: 697

Answers (2)

multitudes
multitudes

Reputation: 3525

Using the views' frames modifier

This can be done using a frame modifier.
Try to create an HStack and each view in it will get the same frame modifier as .frame(minWidth: 0, maxWidth: .infinity).
This will equally distribute the views.

Looking at your code I think this could work.

struct WidgetEntryView : View {
    
  var entry: Provider.Entry
  
  var body: some View {
    ZStack {
      
      Color(entry.color)
      
      VStack(alignment: .leading) {
        Text(entry.name)
        
        Text("Updated in 6 hours")
        // your entry.content needs to be formatted to an HStack

        HStack {
            Text(entry.content)
            .frame(minWidth: 0, maxWidth: .infinity)
            
            Text(entry.content)
            .frame(minWidth: 0, maxWidth: .infinity)
        }
    }
  }
}

See this article too:
SwiftUI: Two equal width columns

Upvotes: 0

Alex
Alex

Reputation: 766

This is certainly not ideal but here's what I came up with. The gist is that I used the truncated text paradigm linked in the question to get the available height. Then I use the width of the widget minus padding to iterate through the text until it can no longer fit in half the width.

Some downsides are that (1) The left column must be half or less than the width of the widget, when in reality it could sometimes fit more content if it was greater, (2) it is difficult to be 100% certain the spacings are all accounted for, and (3) had to hardcode the dimensions of the widget.

In any case, hope this helps anyone looking for a similar solution!

Here's the code with spacings and colors removed for clarity:

struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

extension View {
  func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
    background(
      GeometryReader {
        geometryProxy in
        Color.clear
          .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
      })
      .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
  }
}

struct TruncableText: View {
  let text: Text
  @State private var intrinsicSize: CGSize = .zero
  @State private var truncatedSize: CGSize = .zero
  let isTruncatedUpdate: (_ isTruncated: Bool, _ truncatedSize: CGSize) -> Void
  var body: some View {
    text
      .readSize { size in
        truncatedSize = size
        isTruncatedUpdate(truncatedSize != intrinsicSize, size)
      }
      .background(
        text
          .fixedSize(horizontal: false, vertical: true)
          .hidden()
          .readSize { size in
            intrinsicSize = size
            if truncatedSize != .zero {
              isTruncatedUpdate(truncatedSize != intrinsicSize, truncatedSize)
            }
          })
  }
}

/**
 - Parameter text: The entire contents of the note
 - Parameter size: The size of the text area that was used to initially render the first note
 - Parameter widgetWidth: exact width of the widget for the current family/screen size
 */
func partitionText(_ text: String, size: CGSize, widgetWidth: CGFloat) -> (String, String)? {
  var part1 = ""
  var part2 = text
  
  let colWidth = widgetWidth / 2 - 32 // padding
  let colHeight = size.height
  
  // Shouldn't happen but just block against infinite loops
  for i in 0...100 {
    // Find the first line, or if that doesn't work the first space
    var splitAt = part2.firstIndex(of: "\n")
    if (splitAt == nil) {
      splitAt = part2.firstIndex(of: "\r")
      if (splitAt == nil) {
        splitAt = part2.firstIndex(of: " ")
      }
    }
    
    // We have a block of letters remaining. Let's not split it.
    if splitAt == nil {
      if i == 0 {
        // If we haven't split anything yet, just show the text as a single block
        return nil
      } else {
        // Divide what we had
        break
      }
    }
    
    let part1Test = String(text[...text.index(splitAt!, offsetBy: part1.count)])
    let part1TestSize = part1Test
      .trimmingCharacters(in: .newlines)
      .boundingRect(with: CGSize(width: colWidth, height: .infinity),
                    options: .usesLineFragmentOrigin,
                    attributes: [.font: UIFont.systemFont(ofSize: 12)],
                    context: nil)
    
    if (part1TestSize.height > colHeight) {
      // We exceeded the limit! return what we have
      break;
    }
    
    part1 = part1Test
    part2 = String(part2[part2.index(splitAt!, offsetBy: 1)...])
  }

  return (part1.trimmingCharacters(in: .newlines), part2.trimmingCharacters(in: .newlines))
}


func getWidgetWidth(_ family: WidgetFamily) -> CGFloat {
  switch family {
    case .systemLarge, .systemMedium:
      switch UIScreen.main.bounds.size {
        case CGSize(width: 428, height: 926):   return 364
        case CGSize(width: 414, height: 896):   return 360
        case CGSize(width: 414, height: 736):   return 348
        case CGSize(width: 390, height: 844):   return 338
        case CGSize(width: 375, height: 812):   return 329
        case CGSize(width: 375, height: 667):   return 321
        case CGSize(width: 360, height: 780):   return 329
        case CGSize(width: 320, height: 568):   return 292
        default:                                return 330
      }
    default:
      switch UIScreen.main.bounds.size {
        case CGSize(width: 428, height: 926):   return 170
        case CGSize(width: 414, height: 896):   return 169
        case CGSize(width: 414, height: 736):   return 159
        case CGSize(width: 390, height: 844):   return 158
        case CGSize(width: 375, height: 812):   return 155
        case CGSize(width: 375, height: 667):   return 148
        case CGSize(width: 360, height: 780):   return 155
        case CGSize(width: 320, height: 568):   return 141
        default:                                return 155
      }
    }
}


struct NoteWidgetEntryView : View {
  
  @State var isTruncated: Bool = false
  @State var colOneText: String = ""
  @State var colTwoText: String = ""
  
  var entry: Provider.Entry

  @Environment(\.widgetFamily) var family: WidgetFamily
  
  var body: some View {
    ZStack{
      Color(entry.color)
      
      VStack {
        
        Text(entry.name)
        Text("Updated 6 hours ago")
        
        if entry.twoColumn {
          if (isTruncated) {
            HStack {
              Text(colOneText).font(.system(size:12))
              Text(colTwoText).font(.system(size:12))
            }
          } else {
            TruncableText(text: Text(entry.content).font(.system(size:12))) {
              let size = $1
              if ($0 && colTwoText == "") {
                if let (part1, part2) = partitionText(entry.content, size: size, widgetWidth: getWidgetWidth(family)) {
                  colOneText = part1
                  colTwoText = part2
                  
                  // Only set this if we successfully partitioned the text
                  isTruncated = true
                }
              }
            }
          }
        } else {
          Text(entry.content).font(.system(size:12))
        }
      }
    }
  }
}

Upvotes: 1

Related Questions