Swift で NSAttributedString 内の部分文字列を別の文字列に置換する方法

環境: Swift3

StroyBoard 上で UILabel の文字列を Plain ではなく Attributed で設定し、その文字列内の PlaceHolder を後から別の文字列に置き換えたいケースがあり必要になった。
NSMutableAttributedString#replaceCharacters(in:with:)メソッドを使用する。

まず、NSAttributedString の Extension としてメソッドを追加する。

extension NSAttributedString {

    func replace(pattern: String, replacement: String) -> NSMutableAttributedString {
        let mutableAttributedString = self.mutableCopy() as! NSMutableAttributedString
        let mutableString = mutableAttributedString.mutableString
        while mutableString.contains(pattern) {
            let range = mutableString.range(of: pattern)
            mutableAttributedString.replaceCharacters(in: range, with: replacement)
        }
        return mutableAttributedString
    }
}

そして、以下のように呼び出して使う。

@IBOutlet weak var detailLabel: UILabel?

override func viewDidLoad() {
    super.viewDidLoad()

    if let attributedText = self.detailLabel?.attributedText {
        self.detailLabel?.attributedText = attributedText.replace(pattern: "置き換えたい文字列", replacement: "新しい文字列")
    }
}

UITextView を使ってテキストの一部をハイパーリンク化する

環境: Swift3
iOS10

f:id:xyk:20170518200531g:plain

UILabel ではなく UITextView の attributedText に NSLinkAttributeName をセットすることで簡単にできた。
クリック時にデフォルトでは Safari が起動して設定したURLのページが表示された。
クリック時の挙動を変更したい場合は UITextViewDelegate の
func textView(UITextView, shouldInteractWith: URL, in: NSRange, interaction: UITextItemInteraction)(iOS 10から)
または
func textView(UITextView, shouldInteractWith: URL, in: NSRange) (iOS7,8,9はこちら。iOS 10でDeprecated)
を実装して制御する。
今回は SFSafariViewController で開いてみた。
以下が実装例。

import UIKit
import SafariServices

class ViewController: UIViewController, UITextViewDelegate {

    @IBOutlet weak var textView: UITextView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTextView()
    }
    
    func setupTextView() {
        
        let text = "詳細はこちらをご覧ください"
        
        textView.delegate = self
        textView.isSelectable = true
        textView.isEditable = false
        textView.textContainer.lineFragmentPadding = 0
        textView.textContainerInset = .zero
        textView.isScrollEnabled = false // これが必要な理由は後述
        
        let attributedString = NSMutableAttributedString(string: text)
        let range = NSString(string: text).range(of: "こちら")
        
        attributedString.addAttribute(
            NSLinkAttributeName,
            value: "https://www.google.co.jp/",
            range: range)
        
        textView.attributedText = attributedString
        textView.linkTextAttributes = [NSForegroundColorAttributeName: UIColor.red]
    }

    // MARK: - UITextViewDelegate
    
    // この Delegate の実装しない場合はデフォルトで URL を Safari で開く。
    func textView(_ textView: UITextView,
                  shouldInteractWith URL: URL,
                  in characterRange: NSRange,
                  interaction: UITextItemInteraction) -> Bool {
        
        // UIApplication.shared.open(URL)
        let controller = SFSafariViewController(url: URL)
        self.present(controller, animated: true)
        
        return false
    }

}

これでテキストの一部をハイパーリンク化できる。

UITextView のレイアウト制約について

ここからはハイパーリンク化とは関係のない UITextView の挙動でちょっとハマった話。
上記例での UITextView のレイアウトは Storyboard の AutoLayout を使って行っていたのだが、UITextView の高さの制約を指定しないと制約エラーになった。
バイスによって横幅が変わるので、高さの制約はテキストの長さに応じて自動的に設定して欲しいところ。
UILabel であればテキストが長くて複数行に渡る場合でも intrinsicContentSize が自動的に設定されるので AutoLayout で高さの制約は指定しなくてもよい。
UITextView も同じような挙動を期待していたが、 intrinsicContentSize を見てみたところ、-1(UIViewNoIntrinsicMetric)でサイズ不定となっていたため、高さの制約指定が必要であった。
ただし、これは UITextView の isScrollEnabled プロパティを false とすること(デフォルトはtrue)で intrinsicContentSize が自動設定され、高さの制約指定は不要になることがわかった。
つまり、UITextView は UIScrollView のサブクラスであるため、isScrollEnabled=trueの場合は、枠となる矩形のサイズと、内部のスクロールさせる矩形のサイズは違うため、枠となる矩形側の高さの制約指定は必要となり、isScrollEnabled=falseにすればスクロール不要となるので UILabel と同様に高さが自動的に設定されるのだと思われる。

UITextView の下側のテキストが切れてしまう件

これも余談だが、UITextView に複数行に渡る長いテキストが設定されている場合に下側のテキストが切れてすべて表示されないことがあった。
これはデフォルトのシステムフォントを使っている場合には起きないのだが、ヒラギノフォントを使った場合に発生した。
解決法としては

textView.isScrollEnabled = false
textView.isScrollEnabled = true

というように一旦、isScrollEnabled = falseすればよいらしい。
たしかにこれで解消された。

https://stackoverflow.com/questions/18696706/large-text-being-cut-off-in-uitextview-that-is-inside-uiscrollview

URL Scheme の追加と Configuration によって変更する方法

環境:
Xcode8.3.2
Swift3

URL Scheme の追加

Target -> Info -> URL Types から URL Scheme を追加する。
まだ何も追加していない状態。
f:id:xyk:20170517122753p:plain

+ボタンを押して以下を追加した。

identifier: com.example.myapp
URL Schemes: myapp

f:id:xyk:20170517122758p:plain

Info.plist に URL Types の項目が追加された。

f:id:xyk:20170517123322p:plain

Info.plist のソースコードを直接見ると以下のようになっている。

 <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>com.example.myapp</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>myapp</string>
            </array>
        </dict>
    </array>

Configuration によって URL Scheme を変更する

Release 環境では上で設定したmyappでよいが、Debug 環境ではmyapp-debugに変更したい。
そこで Run Script を使ってコンパイル時に自動で更新させる。

シェルコマンドは Run Script の入力欄に直接を書いてもよいのだがproject.pbxprojに埋め込まれてしまうので別ファイルに切り出すことにする。
以下のようにScirptsディレクトリを作成し、そこにシェルスクリプトファイルを置いた。

f:id:xyk:20170517121830p:plain

シェルスクリプトの中身。CONFIGURATIONDebugのときのみ書き換えるようになっている。
URL Schemes は複数設定できるが今回は1つ目に設定されたものを更新対象にしているので注意。

#!/bin/sh

if [ ${CONFIGURATION} = "Debug" ]; then

lower_conf=`echo ${CONFIGURATION} | tr 'A-Z' 'a-z'`
myapp_url_scheme="myapp-$lower_conf"
info_plist_file_path="${TARGET_BUILD_DIR}/${PRODUCT_NAME}.app/Info.plist"

/usr/libexec/PlistBuddy -x -c "Set :CFBundleURLTypes:0:CFBundleURLSchemes:0 $myapp_url_scheme" "$info_plist_file_path"
updated_myapp_url_scheme=`/usr/libexec/PlistBuddy -c "Print CFBundleURLTypes:0:CFBundleURLSchemes:0" "$info_plist_file_path"`

echo "MyApp URL Scheme set to: $updated_myapp_url_scheme"

fi

Build Phasesから新たな Run Script を追加する。

f:id:xyk:20170517121820p:plain

追加する箇所はcompile sourcescopy bundle resourcesフェーズの終わった後。

f:id:xyk:20170517121823p:plain

Run Scriptのところをクリックすると名前が変更できる。ここでは以下のように変更した。

f:id:xyk:20170517121826p:plain

先ほど作成したシェルスクリプトを実行させる。

. ${PROJECT_DIR}/Scripts/url_schemes.sh

f:id:xyk:20170517125509p:plain

そしてビルドを実行する。
変更されているか Info.plist を確認。

${TARGET_BUILD_DIR} 以下の Info.plist のパス

~/Library/Developer/Xcode/DerivedData/MySample-gbqatlezfkhslgeiregazsgqloel/Build/Products/Debug-iphonesimulator/MySample.app/Info.plist

myapp-debugに書き換わっている。

 <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>com.example.myapp</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>myapp-debug</string>
            </array>
        </dict>
    </array>

Run Script の実行結果はCmd + 8開く Report Navigator に表示されるのだが、echoコマンドで出力したログはAll Issuesには出ないのでAll Messagesの方で確認する。

f:id:xyk:20170517131033p:plain

ちなみに

echo "error: xxx"
echo "warning: xxx"

という書き方をするとAll Issuesの方にもログが出力される。

Fastlane で URL Scheme を更新するやり方は以前書いた。

xyk.hatenablog.com

View の Auto Layout によるアニメーションを無効にする

環境: Swift3

あるViewのSubviewのレイアウトをAuto Layoutで行った時にアニメーションしながら配置された。
この時のアニメーションは不要なので無効にする。
やり方はSubviewの配置が行われるlayoutSubviewsメソッドをオーバーライドして以下のようにアニメーションしないようにする。

方法1

UIView クラスメソッドのperform​Without​Animation:​のブロック内で実行させる。

override func layoutSubviews() {
    UIView.performWithoutAnimation {
        super.layoutSubviews()
    }
}

方法2

CATransaction.setDisableActions(true)を実行後にlayoutSubviewsを実行させる。

override func layoutSubviews() {
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    super.layoutSubviews()
    CATransaction.commit()
}

角丸なUIViewに角丸な影をつける

環境: Swift3

角丸なUIViewに角丸なドロップシャドウをつけるやり方。
プレイグラウンドで確認。

f:id:xyk:20170319023445p:plain

import UIKit
import PlaygroundSupport

let baseView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
baseView.backgroundColor = UIColor(red: 255/255, green: 110/255, blue: 134/255, alpha: 1)

let shadowView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
shadowView.backgroundColor = UIColor(red: 89/255, green: 172/255, blue: 255/255, alpha: 1)
shadowView.center = baseView.center

shadowView.layer.cornerRadius = 10
shadowView.layer.masksToBounds = false

shadowView.layer.shadowColor = UIColor.black.cgColor
shadowView.layer.shadowOpacity = 0.5 // 透明度
shadowView.layer.shadowOffset = CGSize(width: 5, height: 5) // 距離
shadowView.layer.shadowRadius = 5 // ぼかし量

// 以下、角丸パス追加とラスタライズで高速化
shadowView.layer.shadowPath = UIBezierPath(roundedRect: shadowView.bounds, cornerRadius: 10).cgPath
shadowView.layer.shouldRasterize = true
shadowView.layer.rasterizationScale = UIScreen.main.scale

baseView.addSubview(shadowView)

PlaygroundPage.current.liveView = baseView

CGPath の変化をアニメーションさせるサンプル

環境: Xcode8.2.1, Swift3

CGPath の変化をアニメーションさせる方法を試した。
気をつける点としては変更前と変更後のパスの数を同じにしておくこと。

f:id:xyk:20170310125452g:plain

import UIKit
import PlaygroundSupport

class SquareButton: UIControl {
    
    let pathLayer = CAShapeLayer()
    var squarePath: UIBezierPath!
    var halfMoonPath: UIBezierPath!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.squarePath = self.squarePath(rect: frame)
        self.halfMoonPath = self.halfMoonPath(rect: frame)
        
        self.pathLayer.fillColor = UIColor.orange.cgColor
        self.pathLayer.strokeColor = UIColor.white.cgColor
        self.pathLayer.lineWidth = 4
        self.pathLayer.path = self.squarePath.cgPath
        self.layer.addSublayer(self.pathLayer)
    }
    
    override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        super.endTracking(touch, with: event)
        
        self.toggle()
    }
    
    func toggle() {
        
        let anim = CABasicAnimation(keyPath: "path")
        
        if self.isSelected {
            anim.fromValue = self.halfMoonPath.cgPath
            anim.toValue = self.squarePath.cgPath
        } else {
            anim.fromValue = self.squarePath.cgPath
            anim.toValue = self.halfMoonPath.cgPath
        }
        
        anim.duration = 0.4
        anim.fillMode = kCAFillModeForwards
        anim.isRemovedOnCompletion = false
        
        self.pathLayer.add(anim, forKey: "animatePath")
        
        self.isSelected = !self.isSelected
    }
    
    func squarePath(rect: CGRect) -> UIBezierPath {
        
        let initialPoint = CGPoint(x: 0, y: 0)
        let curveStart = CGPoint(x: rect.maxX * 0.05, y: 0)
        let curveControl = CGPoint(x: rect.maxX * 0.5, y: 0)
        let curveEnd = CGPoint(x: rect.maxX * 0.95, y: 0)
        let firstCorner = CGPoint(x: rect.maxX, y: 0)
        let secondCorner = CGPoint(x: rect.maxX, y: rect.maxY)
        let thirdCorner = CGPoint(x: 0, y: rect.maxY)
        
        let myBezier = UIBezierPath()
        myBezier.move(to: initialPoint)
        myBezier.addLine(to: curveStart)
        myBezier.addQuadCurve(to: curveEnd, controlPoint: curveControl)
        myBezier.addLine(to: firstCorner)
        myBezier.addLine(to: secondCorner)
        myBezier.addLine(to: thirdCorner)
        
        myBezier.close()
        return myBezier
    }
    
    func halfMoonPath(rect: CGRect) -> UIBezierPath {
        
        let initialPoint = CGPoint(x: 0, y: 0)
        let curveStart = CGPoint(x: rect.maxX * 0.05, y: 0)
        let curveControl = CGPoint(x: rect.maxX * 0.5, y: rect.maxY * 0.6)
        let curveEnd = CGPoint(x: rect.maxX * 0.95, y: 0)
        let firstCorner = CGPoint(x: rect.maxX, y: 0)
        let secondCorner = CGPoint(x: rect.maxX, y: rect.maxY)
        let thirdCorner = CGPoint(x: 0, y: rect.maxY)
        
        let myBezier = UIBezierPath()
        myBezier.move(to: initialPoint)
        myBezier.addLine(to: curveStart)
        myBezier.addQuadCurve(to: curveEnd, controlPoint: curveControl)
        myBezier.addLine(to: firstCorner)
        myBezier.addLine(to: secondCorner)
        myBezier.addLine(to: thirdCorner)
        
        myBezier.close()
        return myBezier
    }

}

let baseView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 200))
baseView.backgroundColor = UIColor(red: 38.0/255, green: 151.0/255, blue: 68.0/255, alpha: 1)

let button = SquareButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
baseView.addSubview(button)
button.center = baseView.center

PlaygroundPage.current.liveView = baseView

UIScrollView で現在のページ数を取得する

環境: Xcode8.2.1, Swift3

UIScrollViewのisPagingEnabledプロパティを true にすると、ページ単位のスクロールが可能になる。
このときに現在のページ数を求める方法についてメモ。

ページングは横スクロールの場合なら UIScrollView のcontentOffset.xUIScrollView.bounds.widthの半分を超えたところでドラッグを離すと隣のページに進み、半分を超えてなければ元のページに戻る挙動になっている。
まず、その時点のcontentOffset.xからページ数を計算する Extension を追加する。

extension UIScrollView {
    var currentPage: Int {
        return Int((self.contentOffset.x + (0.5 * self.bounds.width)) / self.bounds.width) + 1
    }
}

で、今回はページングのスクロールが完全に止まったタイミングでページ数を取得する方法を考える。

スクロールが完全に止まったタイミングを検出するには前回調べた UIScrollViewDelegate のメソッドに仕掛ければよい。
基本scrollViewDidEndDeceleratingのみで良いと思う(isPagingEnabled=trueはページの区切りまで自動スクロールするので)が、この Delegate はドラッグをピタッと止めた場合は呼ばれないので、その時でも検出できるように念のためscrollViewDidEndDraggingでかつdecelerate=falseの場合にも取得するようにしておく。

// MARK: - UIScrollViewDelegate

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        print("currentPage:", scrollView.currentPage)
    }
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    print("currentPage:", scrollView.currentPage)
}

これでページ数が切り替わった(ページングのスクロールが止まった)時に1度のみページ数が表示される。