SwiftUI中竖向排版文字探索及封装(支持中文及英文)

在移动开发中,有时会遇到需要垂直排版文字的场景,特别是在一些中文设计中,竖排文本可以营造出一种独特的视觉效果。这篇文章将详细讲解如何实现一个支持竖排中文和英文的 SwiftUI 组件,并提供详细的代码实现和思路。

1. 需求分析

在竖排文本的排版中,需要考虑以下几个要点:

  • 字符间距和列间距:即字与字之间以及列与列之间的间隔。
  • 对齐方式:包括文字的左右、上下对齐方式。
  • 旋转角度:英文竖排文本通常需要对其进行90度的旋转。
  • 中英文兼容:中文竖排与英文竖排在对齐方式、排列方式等方面有所不同,需要分别处理。
  • 文本长度处理:当文本长度超过当前显示区域时,需要处理多余文本,可能需要通过省略号等方式进行截断。

为了解决这些问题,我们需要定义一个配置类 VerticalTextConfig,然后在 VerticalText 组件中实现竖排文本的具体逻辑。

2. 代码实现

2.1 VerticalTextConfig 结构体

我们首先定义一个 VerticalTextConfig 结构体,用于配置竖排文本的各种属性,例如字符间距、对齐方式、是否为英文等。

struct VerticalTextConfig {
    /// 字间距
    var itemSpacing: CGFloat = 0.0
    /// 列间距
    var lineSpacing: CGFloat = 10.0
    /// 左右对齐方式, 默认居中,仅支持leading, center, trailing
    var horizontalAlignment: String = "center"
    /// 上下对齐方式, 默认上对齐,仅支持top, center, bottom
    var verticalAlignment: String = "top"
    /// 旋转角度,正直为顺时针旋转,负值为逆时针旋转。
    var rotationDegress: CGFloat = 0.0
    /// 是否是英文
    var isEnglish: Bool = false
    
    // 中文场景下的左右对齐方式
    fileprivate var chineseHorizontalAlignment: Alignment {
        switch horizontalAlignment {
        case "leading": return .leading
        case "center": return .center
        case "trailing": return .trailing
        default: return .center
        }
    }
    
    // 中文场景下的上下对齐方式
    fileprivate var chineseVerticalAlignment: VerticalAlignment {
        switch verticalAlignment {
        case "top": return .top
        case "center": return .center
        case "bottom": return .bottom
        default: return .top
        }
    }
    
    // 英文场景下的左右对齐方式
    fileprivate var englishHorizontalAlignment: Alignment {
        switch horizontalAlignment {
        case "leading": return .bottom
        case "center": return .center
        case "trailing": return .top
        default: return .top
        }
    }
    
    // 英文场景下的上下对齐方式
    fileprivate var englishVerticalAlignment: TextAlignment {
        switch verticalAlignment {
        case "top": return .leading
        case "center": return .center
        case "bottom": return .trailing
        default: return .leading
        }
    }
}

该结构体定义了一系列属性,包括字符间距、列间距、对齐方式等。中文和英文的对齐方式不同,因此分别定义了中文和英文的对齐处理,公开的只有horizontalAlignmentverticalAlignment两个属性,而内部则会将其转换使用内部定义的fileprivate修饰的属性。

2.2 VerticalText 组件

接下来,我们创建 VerticalText 组件,负责渲染竖排文本。

struct VerticalText: View {
    /// 要显示的文字
    let text: String
    /// 字体名字
    @Binding var fontName: String
    /// 字体大小
    @Binding var fontSize: CGFloat
    /// 文字颜色
    @Binding var fontColor: String
    /// 配置文件,包含样式参数
    let config: VerticalTextConfig
    
    init(text: String, fontName: Binding<String>, fontSize: Binding<CGFloat>, fontColor: Binding<String>, config: VerticalTextConfig) {
        self.text = text
        _fontName = fontName
        _fontSize = fontSize
        _fontColor = fontColor
        self.config = config
    }
    
    var body: some View {
        GeometryReader { geometry in
            if config.isEnglish {
                Text(text)
                    .font(.custom(fontName, size: fontSize))
                    .multilineTextAlignment(config.englishVerticalAlignment)
                    .frame(width: geometry.size.height, height: geometry.size.width, alignment: config.englishHorizontalAlignment)
                    .background(Color.red)
                    .rotationEffect(.degrees(90 + config.rotationDegress))
                    .offset(x: -(geometry.size.height - geometry.size.width) / 2, y: (geometry.size.height - geometry.size.width) / 2)
            } else {
                // 计算单个字符的尺寸
                let charSize = calculateCharacterSize(fontName: fontName, fontSize: fontSize)
                // 计算单个字符的宽度
                let charWidth = charSize.width
                // 计算单个字符的高度
                let charHeight = charSize.height
                
                // 计算一列可以容纳的字符数
                let charactersPerColumn = max(1, Int(geometry.size.height / charHeight))
                
                // 将文本拆分成列
                let columns = splitTextIntoColumns(text: text, charactersPerColumn: charactersPerColumn)
                
                // 计算一列的总宽度,包括列间距
                let columnTotalWidth = charWidth + config.lineSpacing
                
                // 计算可以显示的最大列数
                let maxColumns = Int((geometry.size.width + config.lineSpacing) / columnTotalWidth)
                // 计算列数是否超界,如果超界,去掉多余的列,然后将可显示的列最后的字符打点。
                let newColumns = getNewColumnsIfNeeded(columns: columns, maxColumns: maxColumns, charactersPerColumn: charactersPerColumn)
                
                HStack(alignment: config.chineseVerticalAlignment, spacing: config.lineSpacing) {
                    ForEach(newColumns, id: \.self) { column in
                        VStack(spacing: config.itemSpacing) {
                            ForEach(column, id: \.self) { character in
                                Text(character)
                                    .font(.custom(fontName, fixedSize: fontSize))
                            }
                        }
                    }
                }
                .frame(maxWidth: .infinity, alignment: config.chineseHorizontalAlignment)
                .rotationEffect(.degrees(config.rotationDegress))
            }
        }
    }
}

这个组件中,我们使用了 GeometryReader 来计算容器的大小,并根据文本内容以及配置参数来决定如何布局中文或英文竖排文本。主要分为两种情况:

  1. 英文排版:英文排版直接使用 Text 组件,通过设置多行对齐、旋转角度实现竖排显示。
  2. 中文排版:中文排版需要手动计算每个字符的大小,并根据字符高度来将文本拆分为多列。然后使用 HStackVStack 组合来实现垂直排列。

2.3 辅助函数

为了实现中文文本的拆分和字符大小计算,我们还需要在VerticalText组件中添加一些辅助函数:

    // 计算列数是否超界,如果超界,去掉多余的列,然后将可显示的列最后的字符打点。
    func getNewColumnsIfNeeded(columns:  [[String]], maxColumns: Int, charactersPerColumn: Int) -> [[String]] {
        // 如果列数超过最大可显示列数,则进行截断处理
        if columns.count > maxColumns {
            var fixedColumns = Array(columns.suffix(maxColumns))
            // 在最后一列的末尾添加 "..."
            if let lastColumn = fixedColumns.first {
                let newLastColumn = truncateColumnWithEllipsis(column: lastColumn, maxCharacters: charactersPerColumn)
                fixedColumns[0] = newLastColumn
            }
            return fixedColumns
        }
        return columns
    }
    
    // 将文本拆分成列
    func splitTextIntoColumns(text: String, charactersPerColumn: Int) -> [[String]] {
        var columns: [[String]] = []
        var currentColumn: [String] = []
        
        for (index, character) in text.map({ String($0) }).enumerated() {
            currentColumn.append(character)
            if (index + 1) % charactersPerColumn == 0 || index == text.count - 1 {
                columns.append(currentColumn)
                currentColumn = []
            }
        }
        return columns.reversed() // 逆序以实现从右到左的排列
    }
    
    // 计算单个字符的尺寸
    func calculateCharacterSize(fontName: String, fontSize: CGFloat) -> CGSize {
        let uiFont = UIFont(name: fontName, size: fontSize)
        let label = UILabel()
        label.font = uiFont
        label.text = "测" // 用一个中文字符来测量高度
        label.sizeToFit()
        return label.frame.size
    }
    
    // 在最后一列的末尾添加 "..."
    func truncateColumnWithEllipsis(column: [String], maxCharacters: Int) -> [String] {
        var truncatedColumn = column
        if truncatedColumn.count >= maxCharacters {
            truncatedColumn.removeLast()
            truncatedColumn.append("︙")
        }
        return truncatedColumn
    }

这些函数分别负责将文本拆分为多列、计算字符大小,以及在文本过长时对列进行截断。

3. 使用示例

struct VerticalTextDemo: View {
    @State private var fontName: String = "HelveticaNeue"
    @State private var fontSize: CGFloat = 16.0
    @State private var isEnglish: Bool = false
    @State private var fontColor: String = "#000000"
    
    var body: some View {
        VStack {
            
            VStack {
                Text("Helvetica-Bold")
                    .font(.custom("Helvetica-Bold", size: 22))
                    .onTapGesture {
                        fontName = "Helvetica-Bold"
                        fontSize = 22
                    }
                Text("HelveticaNeue-Light")
                    .font(.custom("HelveticaNeue-Light", size: 16))
                    .onTapGesture {
                        fontName = "HelveticaNeue-Light"
                        fontSize = 16
                    }
                Text("KohinoorBangla-Semibold")
                    .font(.custom("KohinoorBangla-Semibold", size: 30))
                    .onTapGesture {
                        fontName = "KohinoorBangla-Semibold"
                        fontSize = 30
                    }
                    .multilineTextAlignment(/*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/)
            }
            if isEnglish {
                VerticalText(text: "Hello world, this is a beautifull world, i love it!", fontName: $fontName, fontSize: $fontSize, fontColor: $fontColor, config: VerticalTextConfig(horizontalAlignment: "center", verticalAlignment: "center", isEnglish: isEnglish))
                    .frame(width: 100, height: 240) // 设置宽度和高度
                    .background(Color.yellow)
            } else {
                VerticalText(text: "这是一个实现竖排文本的例子,不知道好不好用!", fontName: $fontName, fontSize: $fontSize, fontColor: $fontColor, config: VerticalTextConfig(horizontalAlignment: "trailing", verticalAlignment: "top", isEnglish: isEnglish))
                    .frame(width: 100, height: 240) // 设置宽度和高度
                    .background(Color.yellow)
                    .rotationEffect(.degrees(45))
            }
            
        }
    }
}

示例效果如下:

4. 结论

通过本文介绍的 VerticalText 组件,你可以轻松实现中文和英文的竖排文本排版效果。该组件不仅支持字符间距、对齐方式等个性化配置,还能处理文本过长的情况,让你的竖排文本显示更加灵活。

最后,希望能够帮助到有需要的朋友,如果觉得有帮助,还望点个赞,添加个关注,笔者也会不断地努力,写出更多更好用的文章。

5. 完整代码

import SwiftUI

struct VerticalTextDemo: View {
    @State private var fontName: String = "HelveticaNeue"
    @State private var fontSize: CGFloat = 16.0
    @State private var isEnglish: Bool = true
    @State private var fontColor: String = "#000000"
    
    var body: some View {
        VStack {
            
            VStack {
                Text("Helvetica-Bold")
                    .font(.custom("Helvetica-Bold", size: 22))
                    .onTapGesture {
                        fontName = "Helvetica-Bold"
                        fontSize = 22
                    }
                Text("HelveticaNeue-Light")
                    .font(.custom("HelveticaNeue-Light", size: 16))
                    .onTapGesture {
                        fontName = "HelveticaNeue-Light"
                        fontSize = 16
                    }
                Text("KohinoorBangla-Semibold")
                    .font(.custom("KohinoorBangla-Semibold", size: 30))
                    .onTapGesture {
                        fontName = "KohinoorBangla-Semibold"
                        fontSize = 30
                    }
                    .multilineTextAlignment(/*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/)
            }
            if isEnglish {
                VerticalText(text: "Hello world, this is a beautifull world, i love it!", fontName: $fontName, fontSize: $fontSize, fontColor: $fontColor, config: VerticalTextConfig(horizontalAlignment: "center", verticalAlignment: "center", isEnglish: isEnglish))
                    .frame(width: 100, height: 240) // 设置宽度和高度
                    .background(Color.yellow)
            } else {
                VerticalText(text: "这是一个实现竖排文本的例子,不知道好不好用!", fontName: $fontName, fontSize: $fontSize, fontColor: $fontColor, config: VerticalTextConfig(horizontalAlignment: "trailing", verticalAlignment: "top", isEnglish: isEnglish))
                    .frame(width: 100, height: 240) // 设置宽度和高度
                    .background(Color.yellow)
                    .rotationEffect(.degrees(45))
            }
            
        }
    }
}


#Preview {
    VerticalTextDemo()
}

struct VerticalTextConfig {
    /// 字间距
    var itemSpacing: CGFloat = 0.0
    /// 列间距
    var lineSpacing: CGFloat = 10.0
    /// 左右对齐方式, 默认居中,仅支持leading, center, trailing
    var horizontalAlignment: String = "center"
    /// 上下对齐方式, 默认上对齐,仅支持top, center, bottom
    var verticalAlignment: String = "top"
    /// 旋转角度,正直为顺时针旋转,负值为逆时针旋转。
    var rotationDegress: CGFloat = 0.0
    /// 是否是英文
    var isEnglish: Bool = false
    
    /// 中文场景使用
    fileprivate var chineseHorizontalAlignment: Alignment {
        switch horizontalAlignment {
        case "leading":
            return .leading
        case "center":
            return .center
        case "trailing":
            return .trailing
        default:
            return .center
        }
    }
    
    /// 中文场景使用
    fileprivate var chineseVerticalAlignment: VerticalAlignment {
        switch verticalAlignment {
        case "top":
            return .top
        case "center":
            return .center
        case "bottom":
            return .bottom
        default:
            return .top
        }
    }
    
    /// 英文场景使用
    fileprivate var englishHorizontalAlignment: Alignment {
        switch horizontalAlignment {
        case "leading":
            return .bottom
        case "center":
            return .center
        case "trailing":
            return .top
        default:
            return .top
        }
    }
    
    /// 英文场景使用
    fileprivate var englishVerticalAlignment: TextAlignment {
        switch verticalAlignment {
        case "top":
            return .leading
        case "center":
            return .center
        case "bottom":
            return .trailing
        default:
            return .leading
        }
    }
}

struct VerticalText: View {
    /// 要显示的文字
    let text: String
    /// 字体名字
    @Binding var fontName: String
    /// 字体大小
    @Binding var fontSize: CGFloat
    /// 文字颜色
    @Binding var fontColor: String
    /// 配置文件,包含样式参数
    let config: VerticalTextConfig
    
    init(text: String, fontName: Binding<String>, fontSize: Binding<CGFloat>, fontColor: Binding<String>, config: VerticalTextConfig) {
        self.text = text
        _fontName = fontName
        _fontSize = fontSize
        _fontColor = fontColor
        self.config = config
    }
    
    var body: some View {
        GeometryReader { geometry in
            if config.isEnglish {
                Text(text)
                    .font(.custom(fontName, size: fontSize))
                    .multilineTextAlignment(config.englishVerticalAlignment)
                    .frame(width: geometry.size.height, height: geometry.size.width, alignment: config.englishHorizontalAlignment)
                    .background(Color.red)
                    .rotationEffect(.degrees(90 + config.rotationDegress))
                    .offset(x: -(geometry.size.height - geometry.size.width) / 2, y: (geometry.size.height - geometry.size.width) / 2)
            } else {
                // 计算单个字符的尺寸
                let charSize = calculateCharacterSize(fontName: fontName, fontSize: fontSize)
                // 计算单个字符的宽度
                let charWidth = charSize.width
                // 计算单个字符的高度
                let charHeight = charSize.height
                
                // 计算一列可以容纳的字符数
                let charactersPerColumn = max(1, Int(geometry.size.height / charHeight))
                
                // 将文本拆分成列
                let columns = splitTextIntoColumns(text: text, charactersPerColumn: charactersPerColumn)
                
                // 计算一列的总宽度,包括列间距
                let columnTotalWidth = charWidth + config.lineSpacing
                
                // 计算可以显示的最大列数
                let maxColumns = Int((geometry.size.width + config.lineSpacing) / columnTotalWidth)
                // 计算列数是否超界,如果超界,去掉多余的列,然后将可显示的列最后的字符打点。
                let newColumns = getNewColumnsIfNeeded(columns: columns, maxColumns: maxColumns, charactersPerColumn: charactersPerColumn)
                
                HStack(alignment: config.chineseVerticalAlignment, spacing: config.lineSpacing) {
                    ForEach(newColumns, id: \.self) { column in
                        VStack(spacing: config.itemSpacing) {
                            ForEach(column, id: \.self) { character in
                                Text(character)
                                    .font(.custom(fontName, fixedSize: fontSize))
                            }
                        }
                    }
                }
                .frame(maxWidth: .infinity, alignment: config.chineseHorizontalAlignment)
                .rotationEffect(.degrees(config.rotationDegress))
            }
        }
        
    }
    
    // 计算列数是否超界,如果超界,去掉多余的列,然后将可显示的列最后的字符打点。
    func getNewColumnsIfNeeded(columns:  [[String]], maxColumns: Int, charactersPerColumn: Int) -> [[String]] {
        // 如果列数超过最大可显示列数,则进行截断处理
        if columns.count > maxColumns {
            var fixedColumns = Array(columns.suffix(maxColumns))
            // 在最后一列的末尾添加 "..."
            if let lastColumn = fixedColumns.first {
                let newLastColumn = truncateColumnWithEllipsis(column: lastColumn, maxCharacters: charactersPerColumn)
                fixedColumns[0] = newLastColumn
            }
            return fixedColumns
        }
        return columns
    }
    
    // 将文本拆分成列
    func splitTextIntoColumns(text: String, charactersPerColumn: Int) -> [[String]] {
        var columns: [[String]] = []
        var currentColumn: [String] = []
        
        for (index, character) in text.map({ String($0) }).enumerated() {
            currentColumn.append(character)
            if (index + 1) % charactersPerColumn == 0 || index == text.count - 1 {
                columns.append(currentColumn)
                currentColumn = []
            }
        }
        return columns.reversed() // 逆序以实现从右到左的排列
    }
    
    // 计算单个字符的尺寸
    func calculateCharacterSize(fontName: String, fontSize: CGFloat) -> CGSize {
        let uiFont = UIFont(name: fontName, size: fontSize)
        let label = UILabel()
        label.font = uiFont
        label.text = "测" // 用一个中文字符来测量高度
        label.sizeToFit()
        return label.frame.size
    }
    
    // 在最后一列的末尾添加 "..."
    func truncateColumnWithEllipsis(column: [String], maxCharacters: Int) -> [String] {
        var truncatedColumn = column
        if truncatedColumn.count >= maxCharacters {
            truncatedColumn.removeLast()
            truncatedColumn.append("︙")
        }
        return truncatedColumn
    }
}

你可能感兴趣的:(SwiftUI,swiftui,swift,VerticalText)