2

I would like to change the formatting of the first line of text in an NSTextView (give it a different font size and weight to make it look like a headline). Therefore, I need the range of the first line. One way to go is this:

guard let firstLineString = textView.string.components(separatedBy: .newlines).first else {
    return
}
let range = NSRange(location: 0, length: firstLineString.count)

However, I might be working with quite long texts so it appears to be inefficient to first split the entire string into line components when all I need is the first line component. Thus, it seems to make sense to use the firstIndex(where:) method:

let firstNewLineIndex = textView.string.firstIndex { character -> Bool in
    return CharacterSet.newlines.contains(character)
}
// Then: Create an NSRange from 0 up to firstNewLineIndex. 

This doesn't work and I get an error:

Cannot convert value of type '(Unicode.Scalar) -> Bool' to expected argument type 'Character'

because the contains method accepts not a Character but a Unicode.Scalar as a parameter (which doesn't really make sense to me because then it should be called a UnicodeScalarSet and not a CharacterSet, but nevermind...).

My question is:

How can I implement this in an efficient way, without first slicing the whole string?

(It doesn't necessarily have to use the firstIndex(where:) method, but appears to be the way to go.)

Mischa
  • 15,816
  • 8
  • 59
  • 117

2 Answers2

2

A String.Index range for the first line in string can be obtained with

let range = string.lineRange(for: ..<string.startIndex)

If you need that as an NSRange then

let nsRange = NSRange(range, in: string)

does the trick.

Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • or simply `"".startIndex..<"".endIndex` – Leo Dabus Feb 22 '19 at 17:27
  • 3
    @LeoDabus: That might work by chance, but collection indices are only meant to be used with their “own” collection. Using `"".startIndex` as index into a different string is (I think) undefined behavior. – Martin R Feb 22 '19 at 17:29
  • you are correct. better to use the standard approach – Leo Dabus Feb 22 '19 at 17:30
  • 1
    @LeoDabus: Or with partial ranges: `.. – Martin R Feb 22 '19 at 17:54
  • Sleek, short, works. I always find it a little "unnatural" to first create some arbitrary range of which we know that it's in the first line. It's not very expressive because there are many possible choices for that range. However, the simplicity of these two line make up for that in my opinion. – Mischa Feb 23 '19 at 04:49
  • Wow, I just understood the immense consequences of working with non-integer indices that only have a meaning with respect to their own collection: If you want to compare two strings by iterating over them character by character, for example, you cannot just use a single index for both strings and increment it in every loop (like `firstString[i] == secondString[i]`) – you have to maintain two different indices and increment both of them with `index(after:)`! That's crazy. – Mischa Feb 23 '19 at 09:26
1

You can use rangeOfCharacter, which returns the Range<String.Index> of the first character from a set in your string:

extension StringProtocol where Index == String.Index {
    var partialRangeOfFirstLine: PartialRangeUpTo<String.Index> {
        return ..<(rangeOfCharacter(from: .newlines)?.lowerBound ?? endIndex)
    }
    var rangeOfFirstLine: Range<Index> {
        return startIndex..<partialRangeOfFirstLine.upperBound
    }
    var firstLine: SubSequence {
        return self[partialRangeOfFirstLine]
    }
}

You can use it like so:

var str = """
    some string 
    with new lines
"""
var attributedString = NSMutableAttributedString(string: str)
let firstLine = NSAttributedString(string: String(str.firstLine))
// change firstLine as you wish

let range = NSRange(str.rangeOfFirstLine, in: str)
attributedString.replaceCharacters(in: range, with: firstLine)
pckill
  • 3,709
  • 36
  • 48
  • 1
    `rangeOfCharacter` is definitely the best approach to find the first newLine index in a String. Btw since Swift 4 you should create a generic method on `StringProtocol` instead of `String` to allow the use of this method on substrings as well – Leo Dabus Feb 22 '19 at 16:48
  • `CharacterSet` is redundant – Leo Dabus Feb 22 '19 at 16:49
  • On your first property one liner `return ..<(rangeOfCharacter(from: .newlines)?.lowerBound ?? endIndex)`. On your second property you need to change the return type to `SubSequence`. No need to initialize a new `String` object – Leo Dabus Feb 22 '19 at 16:58
  • @LeoDabus, I'll add your improvements to the answer, if you don't mind :) – pckill Feb 22 '19 at 17:00
  • I don't. I was gonna post it anyway. – Leo Dabus Feb 22 '19 at 17:00
  • Looks good. The only thing is that I end up with a `PartialRangeUpTo` rather than an `NSRange` which is required when I want to change the first line's attributes using [`replaceCharacters(in range: NSRange, with attrString: NSAttributedString)`](https://developer.apple.com/documentation/foundation/nsmutableattributedstring/1417045-replacecharacters). Is it possible to convert this? – Mischa Feb 22 '19 at 17:09
  • @Mischa you can easily create your range using the original string startIndex and partial range upperBound – Leo Dabus Feb 22 '19 at 17:18
  • `extension StringProtocol where Index == String.Index { var firstLineRange: Range { return startIndex..<(rangeOfCharacter(from: .newlines)?.lowerBound ?? endIndex) } var firstLine: SubSequence { return self[firstLineRange] } }` – Leo Dabus Feb 22 '19 at 17:19
  • 1
    @Mischa, added usage example – pckill Feb 22 '19 at 17:30
  • Thanks a lot for all these ideas! Learned a lot from that. It definitely works and what I like about it is that it's relatively expressive as your going with a range `..<(rangeOfCharacter(from: .newlines)`. This makes it clear to the programmer that you're going from position zero up to the first line break. However, I personally prefer Martin R's solution as it's a lot shorter and feels like this is the way the function is supposed to be used. – Mischa Feb 23 '19 at 04:45