【iOS】DateとStringの相互変換の注意点

今更ですが混乱することがあるのでまとめておく

String->Date

タイムゾーンなどの情報がない場合は現在の端末設定から自動的に タイムゾーンを考慮したDateが返ってくる
※ 地域の設定が日本の場合

let dateString = "2018/01/01 12:00"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm"
print(formatter.date(from: dateString)!)
結果
2018-01-01 03:00:00 +0000

基本的にDateFormatterにタイムゾーンを指定しない場合は
日本の場合UTC+9時間なので以下のようになる(DateはそもそもUTC+0)
Date -> String +9時間
String -> Date - 9時間

ちゃんとタイムゾーンが指定されていればそのタイムゾーンを考慮した時刻を得ることができる(RFC3339)

let dateString = "2018-01-01T12:00:00+09:00"

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
print(formatter.date(from: dateString)!)
結果
2018-01-01 03:00:00 +0000
let dateString = "2018-01-01T12:00:00+00:00"

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
print(formatter.date(from: dateString)!)
結果
2018-01-01 12:00:00 +0000

Date->String

DateFormatterのlocaleにen_US_POSIXを指定すること。そうしないと和暦などの設定によっては正しく変換できない

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") // これ

まとめ

  • タイムゾーンの指定しないと端末の地域設定から自動的に考慮した結果が返される
  • DateはUTC+0
  • DateFormatterのlocaleにen_US_POSIXを指定しないと Date->String の時に意図しない結果になる

【iOS11】UIToolbarの上に乗せたUIButtonやUITextViewのタップイベントが効かない

iOS11対応をしている時にハマったので記録として残しておく。

問題のコード

普通にtoobarの上にボタンを置いているだけ。
iOS10以下なら問題なくボタンのタップイベントを取得できるがiOS11だとボタンのタップが効かなくなる。

  UIToolbar *toolBar = [[UIToolbar alloc]init];
  [self.view addSubview:toolBar];

  UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
  [button setTitle:@"push" forState:UIControlStateNormal];
  [toolBar addSubview:button];

  toolBar.translatesAutoresizingMaskIntoConstraints = NO;
  [toolBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES;
  [toolBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor].active = YES;
  [toolBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor].active = YES;
  [toolBar.heightAnchor constraintEqualToConstant:50];

  button.translatesAutoresizingMaskIntoConstraints = NO;
  [button.topAnchor constraintEqualToAnchor:toolBar.topAnchor].active = YES;
  [button.leadingAnchor constraintEqualToAnchor:toolBar.leadingAnchor].active = YES;
  [button.trailingAnchor constraintEqualToAnchor:toolBar.trailingAnchor].active = YES;
  [button.bottomAnchor constraintEqualToAnchor:toolBar.bottomAnchor].active = YES;

改善

toobarにaddSubViewする前に toobarに対して layoutIfNeededを呼んでやればいいみたい。 サブクラスにしている場合でも、クラス内でtoolbarにaddSubViewする前にlayoutIfNeededを呼べば良い。

ちなみにiOS10以下でlayoutIfNeededを呼んでいても特に問題はなかったのでiOS11用にコードを分岐する必要はなさそう。
toolbarのviewの階層を調べてみたが特に違いはなかったので単純にバグのような気がする。

素直にViewとかで代用した方がいいかもしれない。blur効果が欲しいならUIVisualEffectViewとか。

  UIToolbar *toolBar = [[UIToolbar alloc]init];
  [self.view addSubview:toolBar];
  // 追記
  [toolBar layoutIfNeeded];

  UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
  [button setTitle:@"push" forState:UIControlStateNormal];
  [toolBar addSubview:button];

  toolBar.translatesAutoresizingMaskIntoConstraints = NO;
  [toolBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES;
  [toolBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor].active = YES;
  [toolBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor].active = YES;
  [toolBar.heightAnchor constraintEqualToConstant:50];

  button.translatesAutoresizingMaskIntoConstraints = NO;
  [button.topAnchor constraintEqualToAnchor:toolBar.topAnchor].active = YES;
  [button.leadingAnchor constraintEqualToAnchor:toolBar.leadingAnchor].active = YES;
  [button.trailingAnchor constraintEqualToAnchor:toolBar.trailingAnchor].active = YES;
  [button.bottomAnchor constraintEqualToAnchor:toolBar.bottomAnchor].active = YES;

一応Viewの階層をのせておく。

layoutIfNeededを呼んでいない時のToolbarのViewの階層

// iOS10.2
(
    "<UIButton: 0x7feac8d84c20; frame = (0 0; 0 0); opaque = NO; layer = <CALayer: 0x608000423020>>"
)

// iOS11.0.1
(
    "<UIButton: 0x7ffc36443830; frame = (0 0; 0 0); opaque = NO; layer = <CALayer: 0x60000043e740>>"
)

layoutIfNeededを呼んだ後のToolbarのViewの階層

なんか違ってて怖い。

// iOS10.2
(
    "<_UIBarBackground: 0x7f9ff6e736d0; frame = (0 0; 0 0); userInteractionEnabled = NO; layer = <CALayer: 0x600000220740>>",
    "<UIButton: 0x7f9ff6e746a0; frame = (0 0; 0 0); opaque = NO; layer = <CALayer: 0x600000425ec0>>"
)

// iOS11.0.1
 (
    "<_UIBarBackground: 0x7f8cc9c98f20; frame = (0 0; 0 0); userInteractionEnabled = NO; layer = <CALayer: 0x600000629da0>>",
    "<_UIToolbarContentView: 0x7f8cc9c9a090; frame = (0 0; 0 0); layer = <CALayer: 0x60000062a600>>",
    "<UIButton: 0x7f8cc9c9ce10; frame = (0 0; 0 0); opaque = NO; layer = <CALayer: 0x60000062b380>>"
)

【iOS】UITableViewをスクロールしている時にNSTimerが呼ばれない

UITableViewに限らずUIScrollViewを継承しているクラス全部に言えることだと思うが
以下のような処理だとスクロール中にタイマー処理がスキップされる

- (void)setupTimer {
    NSTimer *timer = [NSTimer timerWithTimeInterval:3.0
                                             target:self
                                           selector:@selector(timerUpdate)
                                           userInfo:nil repeats:YES];
}
- (void)timerUpdate {
  NSLog(@"timer呼ばれた");
}

改善コード

mainRunLoopにタイマーを追加してやれば良い

- (void)setupTimer {

    NSTimer *timer = [NSTimer timerWithTimeInterval:3.0
                                             target:self
                                           selector:@selector(timerUpdate)
                                           userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
}
- (void)timerUpdate {
  NSLog(@"timer呼ばれた");
}

【Fastlane】deliverを使用してストア情報を更新する

環境

  • Xcode 8.3.2
  • Fastlane 2.45.0

前提条件

  • bundlerを使用してfastlaneをインストー

セットアップ

まずは以下のコマンドを実行して環境を整えます

bundle exec fastlane deliver init

Appfileに記述されているアカウント情報を確認しているようです。
確認出来ない場合は入力を促されると思います

確認される項目
  • AppleId (メールアドレス)
  • password (一度入力するとkeychainに保存される)
  • Bundle Identifier

また、確認されたBundle Identifierを使用して作成したストアページがない場合はエラーが出ます 。なので作ってない場合は先に作りましょう。

初期化後のディレクトリ構造の例
fastlane/  
┣ AppFile  
    ┣ DeliverFile   
    ┣ FastFile 
    ┣ metadata
        ┣ copyright.txt
        ┣ en-US
             ┣ description.txt
             ┣ keywords.txt
             ┣ marketing_url.txt
             ┣ name.txt
             ┣ privacy_url.txt
             ┣ promotional_text.txt
             ┣ release_notes.txt
             ┣ subtitle.txt
             ┣ support_url.txt
        ┣ ja
             ┣ description.txt
             ┣ keywords.txt
             ┣ marketing_url.txt
             ┣ name.txt
             ┣ privacy_url.txt
             ┣ promotional_text.txt
             ┣ release_notes.txt
             ┣ subtitle.txt
             ┣ support_url.txt
        ┣ primary_category.txt
        ┣ primary_first_sub_category.txt
        ┣ primary_second_sub_category.txt
        ┣ review_information
             ┣ demo_password.txt
             ┣ demo_user.txt
             ┣ email_address.txt
             ┣ first_name.txt
             ┣ last_name.txt
             ┣ notes.txt
             ┣ phone_number.txt
        ┣ secondary_category.txt
        ┣ secondary_first_sub_category.txt
        ┣ secondary_second_sub_category.txt
        ┣ trade_representative_contact_information
             ┣ address_line1.txt
             ┣ address_line2.txt
             ┣ city_name.txt
             ┣ country.txt
             ┣ email_address.txt
             ┣ first_name.txt
             ┣ is_displayed_on_app_store.txt
             ┣ last_name.txt
             ┣ phone_number.txt
             ┣ postal_code.txt
             ┣ state.txt
             ┣ trade_name.txt
    ┣ screenshots
          ┣ en-US
              ┣ xxxxx.png 
          ┣ README.txt

今のストアの状態をそのまま取ってきてくれるみたいなので、後から導入するのも楽そうです。

DeliverFile

DeliverFileに変更したい項目を記載してコマンドを叩きます。

DeliverFileの例
# リリースノート
release_notes({
  'en-US' => "Try the new version",
  'ja' => "新しいバージョンをお試し下さい"
})

# アプリの説明
description({
  'en-US' => "English Description\nEnglish Description\nEnglish Description\n",
  'ja' => "日本語の説明文\n日本語の説明文\n日本語の説明文\n"
})

# キーワード
keywords(
  'en-US' => "Keyword1,Keyword2",
   'ja' => "キーワード1,キーワード2"
)

# HTMLで内容確認を行わない場合'true'
# デフォルトは `false`なので内容確認のHTMLが表示されます
# force true

こんな感じで変更したい項目を記述していきます。
まぁ後から入れた場合はほぼほぼ'release_notes'だけになると思いますが。。
変更したくない項目は記載しなければ大丈夫です。

以下のコマンドでDeliverFileの内容でitunesConnectを更新します。

bundle exec fastlane deliver

現在のitunesConnectの情報にmetadata配下を更新したい場合は以下のコマンドを実行します。 itunesConnectを更新したあと実行するといいと思います。

bundle exec fastlane deliver download_metadata

スクリーンショット

f:id:y-hryk:20170710113858p:plain

上記のようにscreenshots配下におきます。

アップロードのルール
  • 並び順はフォルダ内の順番と同じ
  • サイズは自動判定されて適切な場所にアップロードされる (5.5インチとか4.7インチとかの話です)

名前の付け方で上手く運用できそう。

【Fastlane】The following build commands failed CopySwiftLibs エラーが出た時

ローカル環境では特に問題ないけど、ビルドマシンのmac-miniにsshでログインしてgymを実行したり mac-miniのjenkinsからgymを実行すると 以下エラー等が出て困った。

  • The following build commands failed:CopySwiftLibs
  • The following build commands failed:CodeSign

ビルドする端末のキーチェーンアクセスを開いて該当する証明書をログインではなく、システムに入れればいいみたい。

余談ですが、上記エラーと一緒に以下のエラーが出ていました。

[16:37:53]: [31mExit status: 65[0m
[16:37:53]: [33m📋  For a more detailed error log, check the full log at:[0m
[16:37:53]: [33m📋  /Users/yamanaka/Library/Logs/gym/MEME-Run-iOS-MEME-Run-iOS.log[0m
[16:37:53]: [31mFound multiple versions of Xcode in '/Applications/'[0m
[16:37:53]: [31mMake sure you selected the right version for your project[0m
[16:37:53]: [31mThis build process was executed using '/Applications/Xcode.app'[0m
[16:37:53]: [33mIf you want to update your Xcode path, either[0m
[16:37:53]: 
[16:37:53]: - Specify the Xcode version in your Fastfile
[16:37:53]: ▸ [35mxcversion(version: "8.1") # Selects Xcode 8.1.0[0m
[16:37:53]: 
[16:37:53]: - Specify an absolute path to your Xcode installation in your Fastfile
[16:37:53]: ▸ [35mxcode_select "/Applications/Xcode8.app"[0m
[16:37:53]: 
[16:37:53]: - Manually update the path using
[16:37:53]: ▸ [35msudo xcode-select -s /Applications/Xcode.app[0m
[16:37:53]: 

僕の環境だと端末内に複数バージョンのXcodeが入っていたため、上記エラーの通りXcodeの指定がちゃんとできていないと思ってこの辺りを中心に調査して盛大に時間をロスしました。(ビルドに使用するXcodexcode_selectアクションで指定できます。 )

xcode_select "/Applications/Xcode.app"

上記のアクションを実行するようにしてFastlaneのビルドログを見ると以下のように使用するXcodeのpathが がちゃんと指定されているのにも関わらず Xcode指定してくれエラーが出続けました。

+----------------------+-------------------------------------------------------------+
|                               Summary for gym 2.36.0                               |
+----------------------+-------------------------------------------------------------+
| scheme               | Demo                                                        |
| export_method        | enterprise                                                  |
| configuration        | AdHoc                                                       |
| use_legacy_build_api | false                                                       |
| output_directory     | ./fastlane/ipa/enterprise/                                  |
| project              | ./Demo.xcodeproj                                            |
| destination          | generic/platform=iOS                                        |
| output_name          | Demo                                                        |
| build_path           | /Users/xxxxxxx/Library/Developer/Xcode/Archives/2017-06-07 |
| clean                | false                                                       |
| silent               | false                                                       |
| skip_package_ipa     | false                                                       |
| buildlog_path        | ~/Library/Logs/gym                                          |
| xcode_path           | /Applications/Xcode.app                                     |
+----------------------+-------------------------------------------------------------+

結局タイトルのエラーを解決したらこのXcode指定してくれエラーは消えて正常にビルドできるようになりました。

端末に複数バージョンXcodeが入っていて、かつThe following build commands failedが出ている場合に一緒に上記のエラーが出ているような気がします。

Exit status: 65- Found multiple versions of Xcode in '/Applications/' · Issue #8327 · fastlane/fastlane · GitHub

このissuesとかもXcodeの指定の問題じゃなくてThe following build commands failedエラーが出ていることが原因なんじゃないかなー。。

一枚のUIImageViewで画像の入れ替えアニメーションを行う

コードは以下の通りです

let transition = CATransition()
transition.duration = 0.75
transition.type = kCATransitionFade

self.myImageView.image = UIImage(named: "image2.png")
self.myImageView.layer.add(transition, forKey: nil)

また以下のコードでアニメーションの終了タイミングを取得することができます

CATransaction.begin()
      
CATransaction.setCompletionBlock({ 
      // 終了時に呼ばれる
})
            
let transition = CATransition()
transition.duration = 0.75
transition.type = kCATransitionFade
self.thumbnailImage.image = UIImage(named: "image2.png")
self.thumbnailImage.layer.add(transition, forKey: nil)

CATransaction.commit()

delegateも用意されている

class Myclass {

  func startAnimation() {
      let transition = CATransition()
      transition.duration = 0.75
      transition.type = kCATransitionFade
      transition.delegate = self
      self.thumbnailImage.image = UIImage(named: "image2.png")
      self.thumbnailImage.layer.add(transition, forKey: nil)
  }

}
extension Myclass: CAAnimationDelegate {

 func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        print("アニメーション終了")
 }

PHAssetからファイル名や拡張子を取得する

PHAssetからファイル名を取得しようとしたらちょっと苦労したのでメモ

PHAssetのメンバにはそれっぽいものがなかった。
結論を言うと

  • Private Apiで取得
  • PHImageManager経由で取得

上記のどちらかのようだ。

Private Apiで取得

// 'asset'は PHAssetのインスタンス
let filename = asset.value(forKey: "filename")

PHImageManager経由で取得

PHImageManagerの関数の戻り値から取得できる
画像とビデオでそれぞれ取得方法が違った

// 'asset'は PHAssetのインスタンス

 switch asset.mediaType {
     case .image:

        let option = PHImageRequestOptions()
        option.deliveryMode = .highQualityFormat

        PHImageManager.default().requestImage(for: asset,
                                      targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight),
                                      contentMode: .aspectFill,
                                      options: option) { (image, info) in
                                                        
                                         if let url = info?["PHImageFileURLKey"] as? URL {
                                                 print(url.lastPathComponent)
                                                 print(url.pathExtension)
                                         }         
         }
        
     case .video:

        let option = PHVideoRequestOptions()
        option.deliveryMode = .highQualityFormat
                
        PHImageManager.default().requestAVAsset(forVideo: asset,
                                      options: option,
                                      resultHandler: { (avAsset, audioMix, info) in
                                                        
                                           if let tokenStr = info?["PHImageFileSandboxExtensionTokenKey"] as? String {
            
                                              let tokenKeys = tokenStr.components(separatedBy: ";")
                                              let urlStr = tokenKeys.filter { $0.contains("/private/var/mobile/Media") }.first
                                                            
                                                   if let urlStr = urlStr {
                                                        if let url = URL(string: urlStr) {
                                                              print(url.lastPathComponent)
                                                              print(url.pathExtension)
                                                        }
                                                   }
                                           }
          })

     default: break
}