20190310のSwiftに関する記事は10件です。

リンクのみタップ&選択可能な UITextView

UITextView 中の、リンクのタップ可と、テキストの選択不可を両立させる方法です!

UITextView にタップ可能なリンクを挿入すると…

リンクをタップできるUITextViewのサンプルコード (タップで開く)
リンクをタップできるUITextView
import UIKit

class LinkTextViewController: UIViewController {
    let textView: UITextView = {
        let textView = UITextView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
        textView.isSelectable = true
        textView.isEditable = false
        let text = "リンク集\nYahoo!\nGoogle"
        let attributedString = NSMutableAttributedString(string: text)
        attributedString.addAttribute(.font,
                                      value: UIFont.systemFont(ofSize: 32),
                                      range: NSRange.init(location: 0, length: attributedString.length))
        attributedString.addAttribute(.link,
                                      value: "https://www.yahoo.co.jp/",
                                      range: NSString(string: text).range(of: "Yahoo!"))
        attributedString.addAttribute(.link,
                                      value: "https://www.google.com/",
                                      range: NSString(string: text).range(of: "Google"))
        textView.attributedText = attributedString
        return textView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        view.addSubview(textView)
        textView.center = view.center
    }
}

extension UIViewController: UITextViewDelegate {
    public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        UIApplication.shared.open(URL)
        return false
    }
}

リンクをタップ可能にしたい場合には、isSelectable = true とする必要があるのですが、
そうするとテキストが選択可能になってしまう :scream:

Screen Shot 2019-03-10 at 21.08.36.png

リンクのみタップ&選択可能にする

下記のように、.link 属性しかタップおよび選択できないようにし、
さらに、.link 属性が選択されたとしても、メニューを出さないようにすることで、
リンクだけがタップ可能な (他のテキストは選択できない) UITextView とすることができます :smile:

リンクがタップでき、テキストは選択できないUITextView
    class MyTextView: UITextView {
        // NOTE: リンクテキストしかタップおよび選択できないようにする
        override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
            guard let position = closestPosition(to: point),
                let range = tokenizer.rangeEnclosingPosition(position, with: .character, inDirection: UITextLayoutDirection.left.rawValue) else {
                    return false
            }
            let startIndex = offset(from: beginningOfDocument, to: range.start)
            return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil
        }

        // NOTE: テキストが選択された場合に、コピーなどのメニューを出さないようにする
        override func becomeFirstResponder() -> Bool {
            return false
        }
    }

リンクのみタップ&選択できるUITextViewのサンプルコード (タップで開く)
import UIKit

class LinkTextViewController: UIViewController {
    class MyTextView: UITextView {
        // NOTE: リンクテキストしかタップおよび選択できないようにする
        override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
            guard let position = closestPosition(to: point),
                let range = tokenizer.rangeEnclosingPosition(position, with: .character, inDirection: UITextLayoutDirection.left.rawValue) else {
                    return false
            }
            let startIndex = offset(from: beginningOfDocument, to: range.start)
            return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil
        }

        // NOTE: テキストが選択された場合に、コピーなどのメニューを出さないようにする
        override func becomeFirstResponder() -> Bool {
            return false
        }
    }

    let textView: MyTextView = {
        let textView = MyTextView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
        textView.isSelectable = true
        textView.isEditable = false
        let text = "リンク集\nYahoo!\nGoogle"
        let attributedString = NSMutableAttributedString(string: text)
        attributedString.addAttribute(.font,
                                      value: UIFont.systemFont(ofSize: 32),
                                      range: NSRange.init(location: 0, length: attributedString.length))
        attributedString.addAttribute(.link,
                                      value: "https://www.yahoo.co.jp/",
                                      range: NSString(string: text).range(of: "Yahoo!"))
        attributedString.addAttribute(.link,
                                      value: "https://www.google.com/",
                                      range: NSString(string: text).range(of: "Google"))
        textView.attributedText = attributedString
        return textView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        view.addSubview(textView)
        textView.center = view.center
    }
}

extension UIViewController: UITextViewDelegate {
    public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        UIApplication.shared.open(URL)
        return false
    }
}

どや!テキスト選択できまい!

Screen Shot 2019-03-10 at 21.37.50.png

余談

別に選択できても良いとは思うけれども、
リンクがあるところのテキストだけ選択可能になっていると、ちょっとね…。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift MediaPlayer ミュージックPlaylist一覧取得

概要

ミュージックのPlaylist一覧取得。

ソース

example

import UIKit
import MediaPlayer

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let mPMediaQuery = MPMediaQuery.playlists()
        if let collections = mPMediaQuery.collections {
            print(MPMediaType.music)
            print(collections.count)
            for collection in collections {
                print("-/\\-")
                print("\(collection.mediaTypes)")
                collection.toString()
            }
        }
    }

}

extension MPMediaItemCollection {
    func toString() {
        print("MPMediaItemCollection")
        let keys = [MPMediaPlaylistPropertyName]
        for key in keys {
            if let value = self.value(forKey: key) {
                if let value = value as? Bool {
                    print("- Bool \(key):\(value)")
                }
                else if let value = value as? String {
                    print("- String \(key):\(value)")
                }
                else if let value = value as? Int {
                    print("- Int \(key):\(value)")
                }
            }
        }
    }
}

プレイリスト名取得

MPMediaItemCollectionのMPMediaPlaylistPropertyName。

memo

自分で作成したPlaylistはMPMediaType 1(MPMediaType.music)です。
MPMediaType 0は「トップレート」「最近再生した項目」「最近追加した項目」等です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Pinterest風にジェスチャで画面遷移するサンプル

preview.gif

iOS版Pinterestのような画面を引き下げて前の画面に遷移するジェスチャーとアニメーションを再現してみました。

サンプルコードはこちらにあります。
GitHub - PinterestLikePullToPop

概略

UINavigationControllerスタック上のview controllerの遷移になります。まずは対象のUIScrollViewUIPanGestureRecognizerをあらたに付加してスクロールと連動。そしてキャプチャした複数のviewをwindow上に重ねてアニメーションを再現。前の画面に戻るときにUIViewControllerAnimatedTransitioningを使って後始末をしています。

ここで使われているアニメーションの一部はこちらの記事に抜き出しています。
Panジェスチャ後のvelocityを利用した放物線アニメーション

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

なっとく!アルゴリズム(p32)をSwiftで書く(配列の要素を小さいものから順に並べていくサンプルコード)

配列の要素を小さいものから順に並べていくサンプルコード

import Foundation

//配列を入れると、一番小さいindexを返す
func findSmallest(array: [Int]) -> Int{
    var smallest = array[0]
    var smallestIndex = 0
    //iは0~ 配列の数
    let totalArray = array.count - 1

    for i in smallestIndex ... totalArray {
        if array[i] < smallest {
            smallest = array[i]
            smallestIndex = i
        }

    }
    return smallestIndex
}

//新しい配列をつくる
func selectionSort(array:[Int]) -> [Int] {
    var inputArray = array
    var newArray = [Int]()
    var totalArray = inputArray.count - 1

    for i in 0 ... totalArray {
        //pytonでいう array.pop(smallest)がない? 指定した位置の要素を削除し、値を取得みたいなやつ
        //配列の中の一番小さい値のはいっているindexを習得したら、新しい配列に入れて配列から消す
        let smallest = findSmallest(array:inputArray)
        newArray.append(array[smallest])
        inputArray.remove(at:smallest)
    }
    return newArray
}

var array = [6,4,1]
print(selectionSort(array:array))

実行結果

[1, 4, 6]

Paizaも
https://paiza.io/projects/6jP7hjM4PqqVMQVRPAQMKg?locale=en-us

躓いたところ

func selectionSort(array:[Int]) -> [Int] {
    var newArray = [Int]()
    var totalArray = array.count - 1

    for i in 0 ... totalArray {
        //pytonでいう array.pop(smallest)がない? 指定した位置の要素を削除し、値を取得みたいなやつ
        //配列の中の一番小さい値のはいっているindexを習得したら、新しい配列に入れて配列から消す
        let smallest = findSmallest(array:array)
        newArray.append(array[smallest])
        array.remove(at:smallest)
    }
    return newArray
}

エラー内容

入れている配列は変数のはずなのになぜこういうエラーが出るのか不思議だった

main.swift:30:9: error: cannot use mutating member on immutable value: 'array' is a 'let' constant
        array.remove(at:smallest)

参考文献のリンクを参照し、もしかして値渡しをしなくてはいけないのかなと思い書き直した。
また、関数引数は定数(let)はこういう問題に直面しないと、知らないなとも思った

Swiftは関数引数は定数(let)。関数引数を直接書き換えることはできない。

参考文献

Swiftの値型と参照型、値渡しと参照渡し

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

なっとく!アルゴリズム二分探索(p9)をSwiftで書く

はじめに

なっとく!アルゴリズムの理解を深めるために、慣れているSwiftで書いてみた時のメモ

import Foundation

//第一引数とある配列を渡す
//第二引数、その配列内に特定の数字が入っていたら、その場所を返すようにする
func binarySearch(list:[Int],item:Int) {
    var low = 0
    var high = list.count - 1 

    //取り出した配列の中見を比較して、同じあたいになるまでループする
    //初回 0 < 4
    //2 0 < 1
    while (low < high) {
        print("low",low)
        print("high",high)

        //真ん中を取り出す
        let mid = Int(ceil(Double(low + high) / 2))
        //値をとりだす
        let value = list[mid]

        //とりだした値が、探したい値よりおおきいかちいさいかをしらべる
        if value == item {
            print("正解")
            return print(mid)
        } else if value < item {
            low = mid + 1
        } else {
            high = mid - 1
        }
    }
    return print("当てはまるものはない")

}

let my_list = [1,3,5,7,9]

print(binarySearch(list:my_list,item:9))

paizaでも動かしたのでメモ

https://paiza.io/projects/Zb9KetD1X-U3x7lh1ss07Q

Int(ceil(Double(low + high) / 2))でつまったのでメモ

本はpythonで書かれていて
pythonの//の表現をうまくSwiftで表現できなくて苦戦したのをメモした

mid = (low + high) // 2

https://qiita.com/nakagawa1017/items/11e7404a5f01863ee0ab

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift 計算の順番を間違えて詰まったのでメモ

忘れないようにメモ

はじめに

下記のような変数があったとする

var low = 0
var high = 3

これと

Int(ceil(Double((low + high) / 2)))

これ

Int(ceil(Double(low + high) / 2))

当たり前だが、計算結果が違う

playgroundで実行してみる

import Foundation
var low = 0
var high = 3
print(Int(ceil(Double((low + high) / 2))))
// 1と出力

print(Int(ceil(Double(low + high) / 2)))
// 2と出力

それぞれ分解してみる

print("low + high: ",low + high)
print("(low + high) / 2 :", (low + high) / 2)
print("Double((low + high) / 2)) :",Double((low + high) / 2) )
print("ceil(Double((low + high) / 2)) : ", ceil(Double((low + high) / 2)))
print("Int(ceil(Double((low + high) / 2))) : ",Int(ceil(Double((low + high) / 2))))
low + high:  3
(low + high) / 2 : 1
Double((low + high) / 2)) : 1.0
ceil(Double((low + high) / 2)) :  1.0
Int(ceil(Double((low + high) / 2))) :  1
print("low + high: ",low + high)
print("Double(low + high) : ", Double(low + high))
print("Double(low + high) / 2 :", Double(low + high) / 2)
print("ceil(Double(low + high) / 2) :",ceil(Double(low + high) / 2))
print("Int(ceil(Double(low + high) / 2)) : ", Int(ceil(Double(low + high) / 2)))
low + high:  3
Double(low + high) :  3.0
Double(low + high) / 2 : 1.5
ceil(Double(low + high) / 2) : 2.0
Int(ceil(Double(low + high) / 2)) :  2

Doubleに型を変換するタイミングを間違えて時間をくってしまったのだが、おちついて考えるとまあそうですよね・・・という気持ちになる

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift MediaPlayer ミュージックAlbum一覧取得

概要

ミュージックのAlbum一覧取得。
Apple Musicで追加したAlbumも対象にしたい。

ソース

example

曲数5曲より大きいAlbumを表示。

import UIKit
import MediaPlayer

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let mPMediaQuery = MPMediaQuery.albums()
        if let collections = mPMediaQuery.collections {
            print(collections.count)
            for collection in collections {
                if collection.count > 5 {
                    print("\(collection.items[0].albumTitle!) : \(collection.count)")
                }
            }
        }
    }

}

注意

Info.plistに「Privacy – Media Library Usage Description」追加が必要。
追加しない場合、実行時エラーになります。
「[access] This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app’s Info.plist must contain an NSAppleMusicUsageDescription key with a string value explaining to the user how the app uses this data.」

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Cocoaアプリのバインディングの設定の仕方

Cocoaアプリのバインディングの設定の仕方

Cocoaアプリの作成

CocoaAppを選択する
スクリーンショット 2019-03-10 10.46.24.png

Modelの作成

Modelを作成します

testModel.h
@interface testModel : NSObject

@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *body;

@property (nonatomic, copy) NSString *url;
@property (nonatomic, copy) NSString *imageUrl;
@property (nonatomic, copy) NSDate *updatedAt;

@end
testModel.m
#import "testModel.h"

@implementation testModel

@end

NSArrayControlloerの設定

コントロールパネルからNSArrayControlloerをViewControlloerに追加します。
スクリーンショット 2019-03-10 10.56.44.png
NSArrayControlloerをアウトレットします。

ViewController.h
#import <Cocoa/Cocoa.h>
@interface ViewController : NSViewController
   @property (strong) IBOutlet NSArrayController *arrayCon;
@end

NSArrayControllerのcontentにデータを入れてやる

ViewController.m

#import "ViewController.h"
//モデルを読み込みます
#import "testModel.h"

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    NSMutableArray*dataArray = [NSMutableArray new];

    for(int i = 0;i < 10;i++){
        testModel*testModel1 = [testModel new];
        testModel1.title = [NSString stringWithFormat:@"タイトル %d",i];
        testModel1.body = [NSString stringWithFormat:@"ボティ %d",i];
        [dataArray addObject:testModel1];
    }
    //NSArrayControllerのcontentにデータを入れてやる
    self.arrayCon.content = dataArray;  

}
@end

NSTableViewの設定

NSTableViewを追加する。
TableContent Bind toをチェックを入れる。追加したNSArrayControllerを選択する
スクリーンショット 2019-03-10 11.35.01.png

Cellの設定

Cellには2種類ある

NSTableCellView iOSでいうUITableCellViewのようなもの

NSTextFiledCell 機能がそれだけのもの
NSImageCell
NSComboBoxCell
etc

それぞれバインドの設定が違う

NSTextFiledCellのバインドの設定はNSTableColumnで設定する。
Bind Toを選択する。ModelKeyPathにキー名(titleやbody)を記入する
スクリーンショット 2019-03-10 11.11.40.png

NSTableCellViewはCellの中に入っているNSTextFiledで設定する
Bind Toを選択する。ObjectValue.title
ObjectValue.の後にキー名(titleやbody)を書く
スクリーンショット 2019-03-10 11.55.00.png

NSTextFiledのバインドの設定

NSTextFiled単体のバインドの設定
ValueのところのBindToを選択する
ModelKeyPathにキー名(titleやbody)を記入する
スクリーンショット 2019-03-10 11.13.45.png

Cocoaバインディングの設定は以上になります

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】CollectionViewを再理解する

経緯

WWDC2018の中でCollectionViewに関するセッションがあり
今回はそれに関してまとめてみることで
改めてCollectionViewの動きに関して見直しをしてみました。
https://developer.apple.com/videos/play/wwdc2018/225/

※実は昨年これに関して発表する予定でしたが、
風邪で倒れて発表できなかった経緯もあります:sweat_smile:
https://speakerdeck.com/shiz/0620

3つの主要コンセプト

CollectionViewを構成する要素として主に下記の3つがあります。

  • Layout
  • Datasource
  • Delegate

UICollectionViewLayout

どこにコンテンツをどのように配置するのかを示します。
データなどは関わりません。

https://developer.apple.com/documentation/uikit/uicollectionviewlayout

UICollectionViewDataSource

CollectionViewの内容を提供します。
セクションやアイテムの数なども管理します。

https://developer.apple.com/documentation/uikit/uicollectionviewdatasource

UICollectionViewDelegate

オプショナルなプロトコルです。
CollectionViewに対するユーザのアクションに対応します。

https://developer.apple.com/documentation/uikit/uicollectionviewdelegate

実装を通して理解を深める

以下ではFlowLayoutの実装を見ながら
ColletionViewの動きを見ていきたいと思います。

UICollectionViewFlowLayout

UICollectionViewLayoutのサブクラスです。
UICollectionViewのデフォルトのレイアウトとはこれが指定されています。
縦や横に均等に並べるようなLine-Basedのデザインはこれでカバーできます。

https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout

UICollectionViewFlowLayoutのカスタマイズ(実装)

prepareメソッドをoverrideすることで簡単にカスタマイズすることができます。

prepareメソッド

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617752-prepare

class ColumnFlowLayout: UICollectionViewFlowLayout {

    // After invalidateLayouts
    override func prepare() {
        super.prepare()

        guard let cv = collectionView else { return }        
        self.itemSize = CGSize(width: cv.bounds.inset(by: cv.layoutMargins).size.width, height: 120.0)

        self.sectionInset = UIEdgeInsets(top: self.minimumInteritemSpacing, left: 0.0, bottom: 0.0, right: 0.0)
        self.sectionInsetReference = .fromSafeArea
    }
}

このメソッドはinvalidLayout発生する度に呼ばれます。
itemSizeの指定やsectionInsetsの指定をします。

WWDCの発表の中では、
また、CollectionViewは横向きにした場合、
左右に対するsafeAreaの制約が効いていないように見え、

self.sectionInsetReference = .fromSafeArea

を設定すると解消されると紹介されていましたが
実際試してみると
あってもなくても特に挙動が変わりませんでした:thinking:
ドキュメントにも何も記載がないので謎です。

https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout/2921645-sectioninsetreference?language=objc

itemsizeを可変にしてみる

https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout/1617711-itemsize

FlowaLayoutは
できる限り同じ行に列を詰めようとする性質があり
これを活用することでLandscape時のレイアウトを変更することができます。

上記のprepareメソッドの場合は下記のようになりますが、

Simulator Screen Shot - iPhone X - 2018-06-13 at 11.40.51.png

これを下記のようにitemSizeを設定することで
レイアウトが変わります。

class ColumnFlowLayout: UICollectionViewFlowLayout {

    // After invalidateLayouts
    override func prepare() {
        super.prepare()

        guard let cv = collectionView else { return }

        // 一行で利用できる幅
        let availableWidth = cv.bounds.inset(by: cv.layoutMargins).size.width

        // セルの最小幅を設定
        let minColumnWidth = CGFloat(300.0)

        // 表示可能なカラム数を決定
        let maxNumColumns = Int(availableWidth / minColumnWidth)

        // 1個1個のセルの幅を決定
        let cellWidth = (availableWidth / CGFloat(maxNumColumns)).rounded(.down)

        self.itemSize = CGSize(width: cellWidth, height: 120.0)

        self.sectionInset = UIEdgeInsets(top: self.minimumInteritemSpacing, left: 0.0, bottom: 0.0, right: 0.0)
        self.sectionInsetReference = .fromSafeArea
    }
}

すると下記のようになります。

Simulator Screen Shot - iPhone X - 2018-06-13 at 12.00.57.png

FlowLayoutには
できる限り同じ行にカラムを詰め込もうとする性質もあり
これを活用しています。

UICollectionViewLayoutの継承

LineBaseのFlowLayoutでは対応仕切れないLayoutが必要な場合
UICollectionViewLayoutを継承します。

基本的なメソッド

1つは上記でも出てきたprepareメソッドで
この中でUICollectionViewLayoutAttributesをメモリ上に保存したり
collectionViewContentSizeの計算を行います。

collectionViewContentSize

// CollectionView全体のサイズ
override var collectionViewContentSize: CGSize

これはUIScrollView.contentSizeに設定され
スクロールの量を知るために必要です。

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617796-collectionviewcontentsize

layoutAttributesForItem

// IndexPathのアイテムのAttributesを返す
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617797-layoutattributesforitem

layoutAttributesForElements

// rectの範囲内に存在するアイテムのAttributesを返す
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617769-layoutattributesforelements

shouldInvalidateLayout

shouldInvalidateLayoutはサイズや位置の変更があった場合に呼ばれ、
処理を加えることができます。

結果としてtrueを返すとInvalidateLayoutが呼ばれ
レイアウトの再描画が起きます。
これはスクロール中もずっと呼ばれます。

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    guard let cv = collectionView else { return false }

    return !newBounds.size.equalTo(cv.bounds.size)
}

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout

MosaicLayoutの例

UICollectionViewLayoutの継承の例として
簡単なモザイクアートのようなレイアウトを作成します。

final class MosaicLayout: UICollectionViewLayout {

    var columns = MosaicColumns()

    // 全体のサイズ
    var contentBounds = CGRect.zero

    // 各セルの属性情報を保持
    var cachedAttributes = [IndexPath: UICollectionViewLayoutAttributes]()

    // 今回は3カラム/行に限定    
    var numberOfColumns = 3

    var rowHeight: CGFloat = 0.0

    private var contentWidth: CGFloat {
        get {
            let insets = collectionView!.contentInset
            return collectionView!.bounds.width - (insets.left + insets.right)
        }
    }

    override var collectionViewContentSize: CGSize {
        get {
            let height = columns.smallestColumn.columnHeight
            return CGSize(width: contentWidth, height: height)
        }
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let cv = collectionView else { return false }

        return !newBounds.size.equalTo(cv.bounds.size)
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cachedAttributes[indexPath]
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return cachedAttributes.values.filter { (attributes) -> Bool in
            return rect.intersects(attributes.frame)
        }
    }

    override func prepare() {
        super.prepare()

        guard let _ = collectionView else { return }

        reset()

        createAttributes()
    }    
}

layoutAttributesForElementsのパフォーマンス問題

さらに、WWDCの発表ではlayoutAttributesForElementsの検索処理の効率が悪いということで
バイナリー検索を用いてより高速に動くようにしています。

minYの小さい順に属性の配列を並べることで、
最初にヒットした位置から横続きにどんどん調べていけば
欲しいデータが見つかるという考えです。

サンプルなどがありませんでしたので
仮で実装してみました
(実装がシンプルなの今回はあまり違いは感じられませんでした)

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

    var attributesArray = [UICollectionViewLayoutAttributes]()

    guard let firstMatchIndex = binarySearchAttributes(range: 0...cachedAttributes.endIndex, rect: rect) else { return attributesArray }

    for attributes in cachedAttributes[..<firstMatchIndex.item].reversed() {
        guard attributes.frame.maxY >= rect.minY else { break }
        attributesArray.append(attributes)
    }

    for attributes in cachedAttributes[firstMatchIndex.item...] {
        guard attributes.frame.minY <= rect.maxY else { break }
        attributesArray.append(attributes)
    }
    return attributesArray
}

private func binarySearchAttributes(range: ClosedRange<Int>, rect: CGRect) -> IndexPath? {

    var lower = range.lowerBound
    var upper = range.upperBound

    while (true) {
        let current = (lower + upper) / 2
        let indexPath = IndexPath(item: current, section: 0)

        guard cachedAttributes.count > indexPath.item else { return nil }

        let attributes = cachedAttributes[indexPath.item]
        if rect.intersects(attributes.frame) {
            return indexPath
        } else if lower > upper {
            return nil
        } else {
            if attributes.frame.maxY < rect.minY {
                lower = current + 1
            } else {
                upper = current - 1
            }
        }
    }
}

アニメーション

最後にアニメーションについて考えていきます。
アニメーションに加えてセルの部分更新にも関わってくるので
一緒にみていきたいと思います。

performBatchUpdates

https://developer.apple.com/documentation/uikit/uicollectionview/1618045-performbatchupdates

複数のアニメーションを同時に行う際は
performBatchUpdatesを使います。
これはアニメーションのアップデートを一律で行ってくれます。

DataSourceの更新と
CollectionViewの更新を
updatesクロージャの中で定義します。

collectionView.performBatchUpdates({
    let movedPerson = people[3]

    people.remove(at: 3)
    people.remove(at: 2)

    people.insert(movedPerson, at: 0)

    collectionView.reloadItems(at: [IndexPath(item: 3, section: 0)])
    collectionView.deleteItems(at: [IndexPath(item: 2, section: 0)])
    collectionView.moveItem(at: IndexPath(item: 3, section: 0), to: IndexPath(item: 0, section: 0))
})

これを実行するときれいに...エラーになります。

reason: 'attempt to delete and reload the same index path (<NSIndexPath: 0x6000015c82c0> {length = 2, path = 0 - 3})'

エラーを見てみると
削除とリロードを同じIndexPath(item: 3, section: 0)に行おうとしていると書かれてあります。

しかし
IndexPath(item: 3, section: 0)に対して削除を行っているつもりはありませんでした。
どこで何が起こっているのでしょうか?

performBatchUpdatesの性質

performBatchUpdatesには以下の特徴があります。

CollectionViewの更新は順序が関係ない(メソッド内で決まっている)

つまり
deleteItemsを先に書こうが
insertItemsを先に書こうが
実行される順番としてはdelete->insertになります。

下記は各アクションの一覧です。

アクション 特徴 IndexPathの参照タイミング
Delete 降順にIndexPathを扱う バッチ更新前
Insert 昇順にIndexPathを扱う バッチ更新前
Move 移動前: バッチ更新前 移動後: バッチ更新後
Reload DeleteとInsertを合わせたもの バッチ更新前

Deleteは
更新が走る前のIndexPathを元に降順
にセルを削除し、

Insertは
削除が実行されたあとのIndexPathを元に昇順に
セルの挿入を行うということのようです。

さらにReloadに関してはDeleteとInsertを合わせたものになります。

主要なエラーの原因4つ

上記のアクションを踏まえた上で
performBatchUpdatesのエラーと考えられる原因として
以下の4つがあげられます。

  • 移動させる対象アイテムのIndexPathと削除するIndexPathに一致するものがある
  • 移動させる対象アイテムのIndexPathと追加するIndexPathに一致するものがある
  • 同じ位置に2つ以上のアイテムを移動させようとしている
  • 不正なIndexPathを参照している

今回の場合は
reloadItemsとmoveItemで同じIndexPathを参照していることが原因で
これがエラーの元になっています。

解決策

reloadItemsの更新を別にすれば解決します。

UIView.performWithoutAnimation {

    // リロードは別の更新で行う
    collectionView.performBatchUpdates({
        collectionView.reloadItems(at: [IndexPath(item: 3, section: 0)])
    })
}

collectionView.performBatchUpdates({

    collectionView.deleteItems(at: [IndexPath(item: 2, section: 0)])
    collectionView.moveItem(at: IndexPath(item: 3, section: 0), to: IndexPath(item: 0, section: 0))
})

DataSourceの更新の問題点と注意点

先ほど、CollectionViewの更新は順序が関係ないと記載しましたが、DataSourceは逆に更新の順番が影響します。順番を間違えるとCollectionViewとデータの不整合が生じるなどエラーの原因になります。

そのため、DataSourceの更新は下記のルールに従って行うべきです。

DataSource Updatesの鉄則

・MoveはDelete+Insertに分解する
・全てのDeleteとInsertは一緒に更新する
・Deleteを最初に降順で行う
・Insertは最後に昇順で行う

先ほどの更新の場合だと下記のようになります。

UIView.performWithoutAnimation {
    collectionView.performBatchUpdates({
        collectionView.reloadItems(at: [IndexPath(item: 3, section: 0)])
    })
}

collectionView.performBatchUpdates({

    // 2 updates
    // index2の削除
    // index3のアイテムをindex0に移動する

    // delete item at index2
    // delete item at index3
    // insert item from index3 at index 0

    let movedPerson = people[3]

    // 降順で削除
    people.remove(at: 3)
    people.remove(at: 2)

    // 昇順で挿入
    people.insert(movedPerson, at: 0)

    collectionView.deleteItems(at: [IndexPath(item: 2, section: 0)])
    collectionView.moveItem(at: IndexPath(item: 3, section: 0), to: IndexPath(item: 0, section: 0))
})

Reload Dataは?

確かにあらゆる問題は解決できますが
アニメーションも何も起きず
かなり乱暴なやり方になりますので使わないのが得策です。

今回はperformBatchUpdatesでしたが
他の同じような性質を持ったメソッドに関しても同様のことが言えると思いますので
ぜひドキュメントなどで各アクションが
いつどのタイミングで、何を参照してアクションを起こしているのか
確認してみると良いかもしれません。

まとめ

UICollectionViewは頻繁に使われるViewですが
意外と知らなかったことがあるのではないかとWWDC2018の動画を見て感じました。

ドキュメントやWWDCの動画を改めて見返すと
今まで意識していなかった箇所や発見があり
理解を深めることができるので良いなと思いました:smiley:

もし何か間違いなどございましたら
ご指摘頂けますと幸いです:bow_tone1:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alamofire JSONをPOST

パラメータにJSONを指定する例

curlだとこう

curl -X POST \
  http://api.exsample.com/api/v1/login \
  -H 'Content-Type: application/json' \
  -H 'cache-control: no-cache' \
  -d '{
    "auth": {
        "phone_number": "090xxxxxxxx",
        "password": "password"
    }
}'

Alamofireの場合

let parameters = [
    "auth": [
             "phone_number": "090xxxxxxxx",
             "password": "password"
            ]
    ]

Alamofire.request("http://api.exsample.com/api/v1/login",
  method: .post, 
  parameters: parameters, 
  encoding: JSONEncoding.default, headers: nil)
.responseJSON { response in
    if let result = response.result.value as? [String: Any] {
       print(result)
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む