UILabel
iOS development
Swift programming
mobile UI design
text formatting

Format UILabel with bullet points?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Displaying a bulleted list inside a UILabel is a common requirement in iOS apps, whether you are showing feature lists, terms and conditions, or instructions. UILabel does not have a built-in bullet list mode, so you need to construct one manually using NSAttributedString with paragraph styles and tab stops. This article shows you how to build properly indented bullet lists in both UIKit and SwiftUI.

Basic Bullet Points with Unicode Characters

The simplest approach is to prepend a bullet character (\u{2022}) to each line. This works for quick prototypes but gives you no control over indentation, so wrapped lines align with the bullet rather than with the text.

swift
1let items = ["First item", "Second item", "Third item"]
2let bulletList = items.map { "\u{2022} \($0)" }.joined(separator: "\n")
3
4let label = UILabel()
5label.numberOfLines = 0
6label.text = bulletList

The problem appears when a line wraps: the second line starts at the leading edge, directly under the bullet, instead of aligning with the text after the bullet. To fix this, you need NSAttributedString with paragraph styling.

Proper Indentation with NSAttributedString

The key to well-formatted bullet lists is NSMutableParagraphStyle combined with NSTextTab. You set a headIndent so that wrapped lines align with the text, and a tab stop so the text after the bullet starts at a consistent position.

swift
1import UIKit
2
3func makeBulletList(items: [String],
4                    font: UIFont = .systemFont(ofSize: 16),
5                    bulletColor: UIColor = .label,
6                    textColor: UIColor = .label) -> NSAttributedString {
7
8    let paragraphStyle = NSMutableParagraphStyle()
9    let indentation: CGFloat = 20.0
10    paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: indentation)]
11    paragraphStyle.defaultTabInterval = indentation
12    paragraphStyle.headIndent = indentation
13    paragraphStyle.paragraphSpacing = 6.0
14
15    let bulletAttributes: [NSAttributedString.Key: Any] = [
16        .font: font,
17        .foregroundColor: bulletColor,
18        .paragraphStyle: paragraphStyle
19    ]
20
21    let textAttributes: [NSAttributedString.Key: Any] = [
22        .font: font,
23        .foregroundColor: textColor,
24        .paragraphStyle: paragraphStyle
25    ]
26
27    let result = NSMutableAttributedString()
28
29    for (index, item) in items.enumerated() {
30        let bullet = NSAttributedString(string: "\u{2022}\t", attributes: bulletAttributes)
31        let text = NSAttributedString(string: item, attributes: textAttributes)
32
33        result.append(bullet)
34        result.append(text)
35
36        if index < items.count - 1 {
37            result.append(NSAttributedString(string: "\n"))
38        }
39    }
40
41    return result
42}
43
44// Usage
45let label = UILabel()
46label.numberOfLines = 0
47label.attributedText = makeBulletList(items: [
48    "Automatic indentation for wrapped lines",
49    "Consistent spacing between bullet and text",
50    "Customizable colors and fonts"
51])

The tab character (\t) between the bullet and the text is what triggers the NSTextTab to push the text over to the indentation position. The headIndent property tells the layout engine where to start subsequent (wrapped) lines, keeping everything aligned.

Customizing the Paragraph Style

NSMutableParagraphStyle gives you fine-grained control over list appearance:

swift
1let style = NSMutableParagraphStyle()
2style.headIndent = 24.0           // indent for wrapped lines
3style.firstLineHeadIndent = 0.0   // bullet starts at leading edge
4style.paragraphSpacing = 8.0      // space between items
5style.lineSpacing = 4.0           // space between wrapped lines
6style.tabStops = [
7    NSTextTab(textAlignment: .left, location: 24.0)
8]
  • headIndent controls where the second and subsequent lines of a paragraph begin. Set this equal to your tab stop location.
  • firstLineHeadIndent controls the starting position of the first line (the bullet). Leave this at 0 or set it to create a nested list effect.
  • paragraphSpacing adds vertical space between separate bullet items, making the list easier to scan.

SwiftUI Equivalent with Text

In SwiftUI, you do not have direct access to NSAttributedString, but you can build bullet lists using VStack and HStack for proper alignment.

swift
1import SwiftUI
2
3struct BulletList: View {
4    let items: [String]
5
6    var body: some View {
7        VStack(alignment: .leading, spacing: 8) {
8            ForEach(items, id: \.self) { item in
9                HStack(alignment: .top, spacing: 8) {
10                    Text("\u{2022}")
11                        .font(.body)
12                    Text(item)
13                        .font(.body)
14                        .fixedSize(horizontal: false, vertical: true)
15                }
16            }
17        }
18    }
19}
20
21// Usage
22struct ContentView: View {
23    var body: some View {
24        BulletList(items: [
25            "Automatic text wrapping with proper alignment",
26            "No attributed string boilerplate needed",
27            "Easy to customize spacing and fonts"
28        ])
29        .padding()
30    }
31}

The HStack with .top alignment ensures the bullet stays at the top of the row even when the text wraps to multiple lines. The fixedSize(horizontal: false, vertical: true) modifier tells SwiftUI to let the text grow vertically rather than truncating.

For iOS 15 and later, you can also use AttributedString directly in a SwiftUI Text view, but the VStack/HStack approach is generally simpler and more flexible for bullet lists.

Common Pitfalls

  • Forgetting numberOfLines = 0 on the UILabel. Without this, the label truncates to a single line and your entire bullet list is invisible beyond the first item.
  • Not setting headIndent to match the tab stop location. If headIndent is 0 or mismatched, wrapped lines start under the bullet instead of aligning with the text, producing a jagged layout.
  • Using a plain newline separator without NSTextTab. Joining strings with "\n" and a bullet prefix gives no indentation control and produces poor alignment on any line that wraps.
  • Hardcoding font sizes instead of using Dynamic Type. If you hardcode a font like .systemFont(ofSize: 16) without supporting UIFontMetrics, the list will not respect the user's accessibility text size preferences.
  • Building the attributed string inside a table view cell without caching. Creating NSAttributedString objects is not free. If you rebuild the bullet list every time cellForRowAt is called, scrolling performance degrades on long lists. Cache the attributed string and invalidate it only when the data changes.

Summary

  • Use \u{2022} (bullet character) followed by \t (tab) and an NSTextTab to create properly aligned bullet lists in UILabel.
  • NSMutableParagraphStyle.headIndent controls where wrapped lines begin, which is the key to preventing text from wrapping under the bullet.
  • Set paragraphSpacing to add vertical breathing room between bullet items.
  • In SwiftUI, use VStack and HStack with .top alignment for a simpler approach that handles wrapping automatically.
  • Always set numberOfLines = 0 and consider Dynamic Type support for accessibility.

Course illustration
Course illustration

All Rights Reserved.