xyk blog

最近は iOS 開発の記事が多めです。

UITextField, UITextView がキーボードで隠れないようにする - Swift 版

環境:
Swift 4.2.1
Deployment Target: 11.0

以前にも同じ内容の記事を書いたが久しぶりに Swift でも同じような実装をしたのでメモ。

前提として
- UIViewController.view に UIScrollView を貼り付け
- UIScrollView 上に UITextField, UITextView を貼り付け
- UIScrollView の bottom は view.safeAreaInsets.bottom に合わせる
という条件になっている。

FirstResponder となっている UITextField, UITextView を探す UIView Extension

extension UIView {
    func findFirstResponder() -> UIView? {
        if isFirstResponder {
            return self
        }
        for v in subviews {
            if let responder = v.findFirstResponder() {
                return responder
            }
        }
        return nil
    }
}

Keyboard の Notification.userInfo をマッピングする Struct

struct UIKeyboardInfo {
    let frame: CGRect
    let animationDuration: TimeInterval
    let animationCurve: UIView.AnimationOptions
    
    init?(info: [AnyHashable : Any]) {
        guard
            let frame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue,
            let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
            let curve = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
            else { return nil }
        self.frame = frame
        animationDuration = duration
        animationCurve = UIView.AnimationOptions(rawValue: curve)
    }
}

UIScrollView を探す UIView Extension

extension UIView {
    func findSuperView<T>(ofType: T.Type) -> T? {
        if let superView = self.superview {
            switch superView {
            case let superView as T:
                return superView
            default:
                return superView.findSuperView(ofType: ofType)
            }
        }
        return nil
    }
}

ViewController

class MyViewController: UIViewController {
    
    //@IBOutlet weak var scrollView: UIScrollView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardWillShow(_:)),
                                               name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardWillHide(_:)),
                                               name: UIResponder.keyboardWillHideNotification, object: nil)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
        
        super.viewWillDisappear(animated)
    }
    
    @objc private func onKeyboardWillShow(_ notification: Notification) {
        guard
            let userInfo = notification.userInfo,
            let keyboardInfo = UIKeyboardInfo(info: userInfo),
            let inputView = view.findFirstResponder(),
            let scrollView = inputView.findSuperView(ofType: UIScrollView.self)
            else { return }
        
        let inputRect = inputView.convert(inputView.bounds, to: scrollView)
        let keyboardRect = scrollView.convert(keyboardInfo.frame, from: nil)
        let offsetY = inputRect.maxY - keyboardRect.minY
        if offsetY > 0 {
            let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y + offsetY)
            scrollView.contentOffset = contentOffset
        }
        // 例えば iPhoneX の Portrait 表示だと bottom に34ptほど隙間ができるのでその分を差し引く  
        let contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardInfo.frame.height - view.safeAreaInsets.bottom, right: 0)
        scrollView.contentInset = contentInset
        scrollView.scrollIndicatorInsets = contentInset
    }
    
    @objc private func onKeyboardWillHide(_ notification: Notification) {
        guard
            let inputView = view.findFirstResponder(),
            let scrollView = inputView.findSuperView(ofType: UIScrollView.self)
            else { return }
        scrollView.contentInset = .zero
        scrollView.scrollIndicatorInsets = .zero
    }
}

ある UIColor から別の UIColor に徐々に色を変化させる

環境: Swift 4.2.1

ある UIColor から別の UIColor に徐々に色を変化させるヘルパーメソッド。

extension UIColor {

    static func colorLerp(from: UIColor, to: UIColor, progress: CGFloat) -> UIColor {
        
        let t = max(0, min(1, progress))
        
        var redA: CGFloat = 0
        var greenA: CGFloat = 0
        var blueA: CGFloat = 0
        var alphaA: CGFloat = 0
        from.getRed(&redA, green: &greenA, blue: &blueA, alpha: &alphaA)
        
        var redB: CGFloat = 0
        var greenB: CGFloat = 0
        var blueB: CGFloat = 0
        var alphaB: CGFloat = 0
        to.getRed(&redB, green: &greenB, blue: &blueB, alpha: &alphaB)
        
        let lerp = { (a: CGFloat, b: CGFloat, t: CGFloat) -> CGFloat in
            return a + (b - a) * t
        }
        
        let r = lerp(redA, redB, t)
        let g = lerp(greenA, greenB, t)
        let b = lerp(blueA, blueB, t)
        let a = lerp(alphaA, alphaB, t)
        
        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
}

progress を徐々に変化させることで色が変化していく。

let color = UIColor.colorLerp(from: .white, to: .black, progress: 0.3)

grep , sed コマンドで .xib や .storyboard ファイル内の文字列を置換する

Xcode での検索・置換機能は .xib や .storyboard などの xml ファイルは対象外となるようだ。
仕方がないのでコマンドラインで置換を行う。

例: 文字列FooBarに置換する

grep -rl --include='*.xib' --include='*.storyboard' 'Foo' ./MyProject | xargs sed -i '' 's/Foo/Bar/g'

-r … サブディレクトリも対象にする
-l … ヒットしたファイル名のみ出力する
--include ... ファイル名指定。複数書けば OR 条件

Huawei 端末で Android アプリのログが Logcat に表示されない件

環境:
Android Studio 3.0.1

Android アプリのデバッグログがシミュレータ操作時には Logcat コンソールに表示されるが、Huawei P9 lite端末を接続して操作した場合にはなぜか出力されない。
調べた結果、Huawei 端末側のログ出力設定を変更することで表示されるようになった。

その方法だが

  • 電話アプリを起動し、ダイヤル画面から以下を入力する
*#*#2846579#*#*
  • 隠しメニューが起動するので
    Project Menu > Background Settings > LOG Settings
    から表示したいログを選択

f:id:xyk:20171220124841p:plain:w300

f:id:xyk:20171220124846p:plain:w300

f:id:xyk:20171220124850p:plain:w300

これで表示されるようになった。

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

追記
Swift 5バージョン
xyk.hatenablog.com

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

環境:
Xcode8.3.2
Swift3

URL Scheme の追加

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

+ボタンを押してidentifierURL Schemesを追加した。
URL Schemes にはカンマ区切りで複数入力も可能。

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>

パラメータの詳細についてはAppleのガイドを参照

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に角丸なドロップシャドウをつけるやり方。

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

shadowView.layer.shadowColor = UIColor.black.cgColor
shadowView.layer.shadowOffset = CGSize(width: 5, height: 5) // 影の方向(ここでは右下)
shadowView.layer.shadowOpacity = 0.5 // 影の濃さ
shadowView.layer.shadowRadius = 5 // 影のぼかし量

f:id:xyk:20170319023445p:plain

プレイグラウンドで確認。

import UIKit
import PlaygroundSupport

let view = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
view.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 = view.center

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

shadowView.layer.shadowColor = UIColor.black.cgColor
shadowView.layer.shadowOffset = CGSize(width: 5, height: 5)
shadowView.layer.shadowOpacity = 0.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

view.addSubview(shadowView)

PlaygroundPage.current.liveView = view

例2:
ぼんやりしてない影をちょっとだけをつけたい場合
shadowRadius を小さくするすると以下のようなかんじになった。

f:id:xyk:20190308150318p:plain

import UIKit
import PlaygroundSupport

let view = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
view.backgroundColor = .groupTableViewBackground

let shadowView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
shadowView.backgroundColor = .white
shadowView.center = view.center

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

shadowView.layer.shadowColor = UIColor.black.cgColor
shadowView.layer.shadowOffset = CGSize(width: 2, height: 2)
shadowView.layer.shadowOpacity = 0.1
shadowView.layer.shadowRadius = 0

view.addSubview(shadowView)

PlaygroundPage.current.liveView = view

StackOverFlowにわかりやすいサンプル例があったので転載。

stackoverflow.com f:id:xyk:20190308151603p:plain