【Swift】半角/全角/URL/改行を区別して文字数をカウントする

## はじめに

iPhone向けに、投稿を予約することで好きな時間にTwitterに呟いてくれるアプリをリリースしています。

https://apps.apple.com/jp/app/twi%E3%82%B9%E3%82%B1/id1491291009

興味がある方はぜひ使ってみてください。

この記事は、上記アプリに入力文字数に応じた残入力可能文字数の表示機能を設置する際に調べたことをまとめるための記事です。

### Twitterの文字数カウントの法則
全角文字 → 1文字カウント
半角文字 → 0.5文字カウント
改行 → 0.5文字カウント
URL → どんな長さでも、11.5文字カウント

出典：
> https://afila0.com/twitter-character-count/
> https://pc-chain.com/sns/twitter-character-limit/

古い記事なんかだと改行1文字URLが23文字、みたいになってることもあるが、2017年ごろに更新されたらしい。
> https://www.localfolio.co.jp/blog/twitter-character-count

### やりたいこと
Twitterの文字数カウントの法則に従って、入力された文字数をカウントし、残入力可能文字数を画面に表示する。
Twitterの場合は残り文字数が10文字以下になった場合に表示されるが、今回は常に表示することとする。

## 特定文字の抽出・カウント
### swiftで正規表現での文字列検索方法

以下のコードで文字列内に入力パターンが含まれているかを判断できる。

```swift
extension String {
    func contain(pattern: String) -> Bool {
        guard let regex = try? NSRegularExpression(pattern: pattern, options: NSRegularExpression.Options()) else {
            return false
        }
        return regex.firstMatch(in: self, options: NSRegularExpression.MatchingOptions(), range: NSMakeRange(0, self.count)) != nil
    }
}
```

https://qiita.com/ishino_mezon/items/943156eab280dbd907ca

このpatternsを変えれば色々調べられる。
また、後半を少し変えれば合致した回数を取得するメソッドに変えることができる。

### 半角英数字・記号のカウント

`[ -~]`をpatternに入れれば、含まれる半角文字数を取得できる。

```swift
import UIKit

extension String {
    // 半角文字が存在する場合、その文字数を返す
    func halfSizeCharsCount() -> Int {
        let pattern = "[ -~]"
        let regex = try! NSRegularExpression(pattern: pattern, options: [])
        let results = regex.matches(in: self, options: [], range: NSRange(0..<self.count))
        return results.count
    }
}
```

https://www-creators.com/archives/5187

https://fuuno.net/web02/hankaku/hankaku.html

### 改行コードのカウント

`[\n]`をpatternsに入れれば、含まれる改行文字数を取得できる。

```swift
import UIKit

extension String {
    // 改行コードが存在する場合、その文字数を返す
    func lineFeedCount() -> Int {
        let pattern = "[\n]"
        let regex = try! NSRegularExpression(pattern: pattern, options: [])
        let results = regex.matches(in: self, options: [], range: NSRange(0..<self.count))
        return results.count
    }
}
```

### URLの抽出

以下の記事より抜粋

```swift
import UIKit

extension String {
    // urlが存在する場合、その配列を返す
    func extractURL() -> [URL] {
        let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
        let links = detector.matches(in: self, range: NSMakeRange(0, self.count))
        return links.compactMap { $0.url }
    }
}
```

http://harumi.sakura.ne.jp/wordpress/2021/02/28/%E6%96%87%E5%AD%97%E5%88%97%E3%81%AE%E4%B8%AD%E3%81%8B%E3%82%89url%E3%82%92%E6%8A%BD%E5%87%BA%E3%81%99%E3%82%8B/

## 見た目の文字数をカウントする

見た目の文字数は、`（string).count`で取得できる。

https://dev.classmethod.jp/articles/swift-string-length/

## メソッドの実装

これまでの情報から、対象のtextを引数にしてtwitterと同じ法則で数えた文字数を返すメソッドを実装すると以下。

```swift
func countText(text: String) -> Float {
    let charCount = Float(text.count)
    let halfSizeCharCount: Float = Float(text.halfSizeCharsCount())
    let lineFeedCount: Float = Float(text.lineFeedCount())
    let urls = text.extractURL()
    var count: Float = charCount - halfSizeCharCount / 2 - lineFeedCount / 2
    for url in urls {
       count = count - Float(url.absoluteString.count) / 2 + urlLength
    }
        
    return count
}
```

最初に見た目の文字数を取得する。
そこから半角文字と改行文字は0.5文字なので、0.5文字分多くカウントしてしまっている分を減らす。
最後に、urlはどんな長さでも11.5文字と数えるので、まずはurlの文字列を半角文字としてカウントしてしまっているものを除いて、11.5を足す。

これにより、twitterと同じ数え方の文字列の長さが取得できる。

## textViewのDelegateメソッドへの記述

delegateの設定方法は、以下の記事。
以下の記事だと、twitterライクな文字数カウントができない。
なので、カウント部分を上記メソッドで書き換えれば良い。

https://qiita.com/addson/items/84ace0312eb6499a6de4


## まとめ

Stringクラスのextensionの定義

```swift:String.swift
import UIKit

extension String {
    // urlが存在する場合、その配列を返す
    func extractURL() -> [URL] {
        let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
        let links = detector.matches(in: self, range: NSMakeRange(0, self.count))
        return links.compactMap { $0.url }
    }
    // 半角文字が存在する場合、その文字数を返す
    func halfSizeCharsCount() -> Int {
        let pattern = "[ -~]"
        let regex = try! NSRegularExpression(pattern: pattern, options: [])
        let results = regex.matches(in: self, options: [], range: NSRange(0..<self.count))
        return results.count
    }
    // 改行コードが存在する場合、その文字数を返す
    func lineFeedCount() -> Int {
        let pattern = "[\n]"
        let regex = try! NSRegularExpression(pattern: pattern, options: [])
        let results = regex.matches(in: self, options: [], range: NSRange(0..<self.count))
        return results.count
    }
}
```

viewファイルの記述

```swift
import UIKit

class RealtimeCountViewController: UIViewController, UITextViewDelegate {

    @IBOutlet weak var memoTextView: UITextView!
    @IBOutlet weak var countLabel: UILabel!

    let maxTextLength = 140

    override func viewDidLoad() {
        super.viewDidLoad()

        memoTextView.delegate = self
    }

    func countText(text: String) -> Float {
        let charCount = Float(text.count)
        let halfSizeCharCount: Float = Float(text.halfSizeCharsCount())
        let lineFeedCount: Float = Float(text.lineFeedCount())
        let urls = text.extractURL()
        var count: Float = charCount - halfSizeCharCount / 2 - lineFeedCount / 2
        for url in urls {
            count = count - Float(url.absoluteString.count) / 2 + urlLength
        }
        
        return count
    }

    //textViewの更新時に文字数カウント表示を更新する。
    func textViewDidChange(_ textView: UITextView) {
        let text = textView.text!
        let remaining = maxTextLength - countText(text: text)
        countLabel.text = String(Int(floor(remaining)))
        
        if remaining < 0 {
            countLabel.backgroundColor = .red
        } else {
            countLabel.backgroundColor = .systemGray
        }
    }
}
```