首页 > 解决方案 > 将 SwiftUI 文本包装成两列

问题描述

我正在构建一个小部件,它将包含一些文本,这些文本是短词和短语的列表。像这样的东西:

小部件屏幕截图

因为它是一个简短的项目列表,所以如果它可以包装成两列,效果最好。

这是当前的简单代码(删除了字体和间距):

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)
      }
    }
  }
}

我发现本指南可以告诉我文本是否被截断,但我需要知道哪些文本已被截断,以便我可以Text在右侧添加另一个带有剩余字符的视图。或者理想情况下使用一些本机方法在两个文本视图之间继续文本。

标签: iosswiftswiftui

解决方案


这当然不是理想的,但这就是我想出的。要点是我使用问题中链接的截断文本范例来获得可用高度。然后我使用小部件的宽度减去填充来遍历文本,直到它不再适合一半宽度。

一些缺点是(1)左列必须是小部件宽度的一半或更小,而实际上如果它更大,它有时可以容纳更多内容,(2)很难 100% 确定间距是全部考虑在内,并且 (3) 必须对小部件的尺寸进行硬编码。

无论如何,希望这可以帮助任何寻找类似解决方案的人!

为了清楚起见,这是删除了间距和颜色的代码:

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))
        }
      }
    }
  }
}

推荐阅读