iOS7 TextKit: bullet point alignment

12,619

Solution 1

Below it the code I use to set a bulleted paragraph. This comes straight out of a working app and is used to apply the style to the entire paragraph in response to a user clicking on a formatting button. I have tried to put in all the dependent methods but may have missed some.

Note that I am setting most indents in centimetres and hence the use of the conversion functions at the end of the listing.

I am also checking for the presence of a tab character (no tab key on iOS!) and automatically insert a dash and a tab.

If all you need is the paragraph style then look at the last few methods below where the firstLineIndent etc get set up.

Note that these calls all get wrapped in [textStorage beginEditing/endEditing]. Despite the (IBAction) below the method is not getting called by a UI object directly.

        - (IBAction) styleBullet1:(id)sender
        {
            NSRange charRange = [self rangeForUserParagraphAttributeChange];
            NSTextStorage *myTextStorage = [self textStorage];

            // Check for "-\t" at beginning of string and add if not found
            NSAttributedString *attrString = [myTextStorage attributedSubstringFromRange:charRange];
            NSString *string = [attrString string];

            if ([string rangeOfString:@"\t"].location == NSNotFound) {
                NSLog(@"string does not contain tab so insert one");
                NSAttributedString * aStr = [[NSAttributedString alloc] initWithString:@"-\t"];
                // Insert a bullet and tab
                [[self textStorage] insertAttributedString:aStr atIndex:charRange.location];

            } else {
                NSLog(@"string contains tab");
            }

            if ([self isEditable] && charRange.location != NSNotFound)
            {
                [myTextStorage setAttributes:[self bullet1Style] range:charRange];
            }
        }

        - (NSDictionary*)bullet1Style
        {
            return [self createStyle:[self getBullet1ParagraphStyle] font:[self normalFont] fontColor:[UIColor blackColor] underlineStyle:NSUnderlineStyleNone];

        }

        - (NSDictionary*)createStyle:(NSParagraphStyle*)paraStyle font:(UIFont*)font fontColor:(UIColor*)color underlineStyle:(int)underlineStyle
        {
            NSMutableDictionary *style = [[NSMutableDictionary alloc] init];
            [style setValue:paraStyle forKey:NSParagraphStyleAttributeName];
            [style setValue:font forKey:NSFontAttributeName];
            [style setValue:color forKey:NSForegroundColorAttributeName];
            [style setValue:[NSNumber numberWithInt: underlineStyle] forKey:NSUnderlineStyleAttributeName];

            FLOG(@" font is %@", font);

            return style;
        }

        - (NSParagraphStyle*)getBullet1ParagraphStyle
        {
            NSMutableParagraphStyle *para;
            para = [self getDefaultParagraphStyle];
            NSMutableArray *tabs = [[NSMutableArray alloc] init];
            [tabs addObject:[[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft location:[self ptsFromCMF:1.0] options:nil]];
            //[tabs addObject:[[NSTextTab alloc] initWithType:NSLeftTabStopType location:[self ptsFromCMF:1.0]]];
            [para setTabStops:tabs];
            [para setDefaultTabInterval:[self ptsFromCMF:2.0]];
            [para setFirstLineHeadIndent:[self ptsFromCMF:0.0]];
            //[para setHeaderLevel:0];
            [para setHeadIndent:[self ptsFromCMF:1.0]];
            [para setParagraphSpacing:3];
            [para setParagraphSpacingBefore:3];
            return para;
        }
    - (NSMutableParagraphStyle*)getDefaultParagraphStyle
    {
        NSMutableParagraphStyle *para;
        para = [[NSParagraphStyle defaultParagraphStyle]mutableCopy];
        [para setTabStops:nil];
        [para setAlignment:NSTextAlignmentLeft];
        [para setBaseWritingDirection:NSWritingDirectionLeftToRight];
        [para setDefaultTabInterval:[self ptsFromCMF:3.0]];
        [para setFirstLineHeadIndent:0];
        //[para setHeaderLevel:0];
        [para setHeadIndent:0.0];
        [para setHyphenationFactor:0.0];
        [para setLineBreakMode:NSLineBreakByWordWrapping];
        [para setLineHeightMultiple:1.0];
        [para setLineSpacing:0.0];
        [para setMaximumLineHeight:0];
        [para setMinimumLineHeight:0];
        [para setParagraphSpacing:6];
        [para setParagraphSpacingBefore:3];
        //[para setTabStops:<#(NSArray *)#>];
        [para setTailIndent:0.0];
        return para;
    }
-(NSNumber*)ptsFromCMN:(float)cm
{
    return [NSNumber numberWithFloat:[self ptsFromCMF:cm]];
}
-(float)ptsFromCMF:(float)cm
{
    return cm * 28.3464567;
}

Solution 2

So I've looked around, and here is the extracted minimal code from Duncan's answer to make it work:

NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:yourLabel.text];

NSMutableParagraphStyle *paragrahStyle = [[NSMutableParagraphStyle alloc] init];
[paragrahStyle setParagraphSpacing:4];
[paragrahStyle setParagraphSpacingBefore:3];
[paragrahStyle setFirstLineHeadIndent:0.0f];  // First line is the one with bullet point
[paragrahStyle setHeadIndent:10.5f];    // Set the indent for given bullet character and size font

[attributedString addAttribute:NSParagraphStyleAttributeName value:paragrahStyle
                         range:NSMakeRange(0, [self.descriptionLabel.text length])];

yourLabel.attributedText = attributedString;

And here is the result of that in my app:

Quadratic Master

Solution 3

This is the easiest solution I've found:

let bulletList = UILabel()
let bulletListArray = ["line 1 - enter a bunch of lorem ipsum here so it wraps to the next line", "line 2", "line 3"]
let joiner = "\n"

var paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.headIndent = 10
paragraphStyle.firstLineHeadIndent = 0

let attributes = [NSParagraphStyleAttributeName: paragraphStyle]
let bulletListString = joiner.join(bulletListArray.map { "• \($0)" })

bulletList.attributedText = NSAttributedString(string: bulletListString, attributes: attributes)

the theory being each string in the array acts like a 'paragraph' and the paragraph style gets 0 indent on the first line which gets a bullet added using the map method.. then for every line after it gets a 10 px indent (adjust spacing for your font metrics)

Solution 4

Other answers rely on setting the indent size with a constant value. That means you'll have to manually update it if you're changing fonts, and will not work well if you're using Dynamic Type. Fortunately, measuring text is easy.

Let's say you have some text and some attributes:

NSString *text = @"• Some bulleted paragraph";
UIFont *font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
NSDictionary *attributes = @{NSFontAttributeName: font};

Here's how to measure the bullet and create a paragraph style accordingly:

NSString *bulletPrefix = @"• ";
CGSize size = [bulletPrefix sizeWithAttributes:attributes];
NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new];
paragraphStyle.headIndent = size.width;

We insert this in our attributes and create the attributed string:

NSMutableDictionary *indentedAttributes = [attributes mutableCopy];
indentedAttributes[NSParagraphStyleAttributeName] = [paragraphStyle copy];
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:text attributes:indentedAttributes];

Solution 5

Swift 5

I made an extension for NSAttributedString that adds a convenience initializer which properly indents different types of lists.

extension NSAttributedString {

    convenience init(listString string: String, withFont font: UIFont) {
        self.init(attributedListString: NSAttributedString(string: string), withFont: font)
    }

    convenience init(attributedListString attributedString: NSAttributedString, withFont font: UIFont) {
        guard let regex = try? NSRegularExpression(pattern: "^(\\d+\\.|[•\\-\\*])(\\s+).+$",
                                                   options: [.anchorsMatchLines]) else { fatalError() }
        let matches = regex.matches(in: attributedString.string, options: [],
                                    range: NSRange(location: 0, length: attributedString.string.utf16.count))
        let nsString = attributedString.string as NSString
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)

        for match in matches {
            let size = NSAttributedString(
                string: nsString.substring(with: match.range(at: 1)) + nsString.substring(with: match.range(at: 2)),
                attributes: [.font: font]).size()
            let indentation = ceil(size.width)
            let range = match.range(at: 0)

            let paragraphStyle = NSMutableParagraphStyle()

            if let style = attributedString.attribute(.paragraphStyle, at: 0, longestEffectiveRange: nil, in: range)
                as? NSParagraphStyle {
                paragraphStyle.setParagraphStyle(style)
            }

            paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: indentation, options: [:])]
            paragraphStyle.defaultTabInterval = indentation
            paragraphStyle.firstLineHeadIndent = 0
            paragraphStyle.headIndent = indentation

            mutableAttributedString.addAttribute(.font, value: font, range: range)
            mutableAttributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
        }

        self.init(attributedString: mutableAttributedString)
    }
}

Example usage: How the convenience initializer is used

The number of spaces after each bullet etc. doesn't matter. The code will calculate the appropriate indentation width dynamically based on how many tabs or spaces you decide to have after your bullet.

If the attributed string already has a paragraph style, the convenience initializer will retain the options of that paragraph style and apply some options of its own.

Supported symbols: •, -, *, numbers followed by a period (e.g. 8.)

Share:
12,619

Related videos on Youtube

Frank R.
Author by

Frank R.

Owner/ Founder of publicspace.net. Full-time Mac developer and Dad.

Updated on April 28, 2022

Comments

  • Frank R.
    Frank R. about 2 years

    I'm writing an app for iOS 7 only and I'm trying to get decent formatting on bullet points in a non-editable UITextView.

    It's easy enough to just insert a bullet point character, but of course the left indentation won't follow. What's the easiest way on iOS 7 to set a left indent after a bullet point?

    Thanks in advance,

    Frank

    • Shmidt
      Shmidt over 10 years
      add tabs before and after bullet.
    • Frank R.
      Frank R. over 10 years
      I mean an indent for the entire multi-line paragraph, not just the first line.
  • Frank R.
    Frank R. over 10 years
    Thanks for the code sample. I'm sure it's going to be fun extracting the minimal code but it's an awful lot better than trying to find my way around the TextKit api doc :-)
  • Duncan Groenewald
    Duncan Groenewald over 10 years
    Try the para setFirstLineHeadIndent and setHeadIndent and setTabStops, you will likely require all three together to create a properly indented bullet paragraph. However bear in mind you are setting the paragraph style so you may get some weird results if you don't set everything.
  • mbm29414
    mbm29414 over 9 years
    @DuncanGroenewald Thanks for this! FWIW, I used a very scaled-down version of this to do a numbered list. Basically, I set up an NSTextTab with a value of "X" (20.0f in my case) and set my headIndent to the same value. Then, my string was @"1.\t{long text here}\n2.\t{more long text}". It got everything aligned just perfectly, even with line wrapping. THANKS!
  • Lukas Petr
    Lukas Petr about 7 years
    FYI, if you're looking for the extracted minimal code, scroll down to my answer: stackoverflow.com/a/26715297/1459762