カテゴリー
iOS Swift

BehaviorRelayとUITableViewのBinding

let dataService = DataService.shared // some singleton class 
let bag = DisposeBag()

@override func viewDidLoad() {


  dataService.someRelay.bind(to: tableView.rx.items(cellIdentifier: CustomTVCell.identifier, cellType: CustomTVCell.self)) { row, object, cell in
    self.dataCount = self.dataService.someRelay.value.count
    cell.configure(index: row, viewWidth: self.viewWidth, viewHeight: self.viewHeight, rowHeight: self.tableView.rowHeight, obj: object)
  }
  .disposed(by: bag)

}
カテゴリー
iOS Swift

UICollectionViewのDrag and Drop

このStanford Universityの授業が参考になりました。

Sample Code

UICollectionViewのCellをDrag & Dropする場合は、専用のAPIが用意されています。UITableViewにもほぼ同じAPIがあります。

まず、collectionView.dragDelegate = selfとすることで、ViewControllerをDelegateに設定することが出来ます。

dragDelegateで重要なのはitemsForBeggingメソッドです。また、dragSessionWillBeginメソッドとdragSessionDidEndメソッドでdragSessionの状態をトラッキング出来るので、それを利用してisDraggingプロパティを更新しています。

extension ViewController: UICollectionViewDragDelegate {
  
  // modifying the isDragging state according to the dragSession lifecycle
  func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession) {
    isDragging = true
  }
  
  func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) {
    isDragging = false
  }
  
  func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    // in order for session to know the localContext. This will be used in dropSessionDidUpdate method
    session.localContext = collectionView
    let itemProvider = NSItemProvider(object: colors_A[indexPath.item])
    let dragItem = UIDragItem(itemProvider: itemProvider)
    dragItem.localObject = indexPath.item
    return [dragItem]
  }
}

DropDelegateで重要なのは、そのドラッグが開始されたのがそのCollectionViewの中か外かを判別し、中であればDropProposalのoperationを.moveに、外であれば.copyを設定することが出来ます。これにより、同一CollectionView内のドラッグの場合は順番入替(reorder)を、外からのドラッグの場合は新規挿入を、と実装を分けることが可能になります。

また、自身以外のUIViewをドラッグ先(DragDestination)とする場合は以下のように実装します。

class ViewController: UIViewController {

  lazy var trashImageView: UIImageView = {
    let imageView = UIImageView(image: UIImage(systemName: "trash"))
    let dropInteraction = UIDropInteraction(delegate: self)
    imageView.addInteraction(dropInteraction)
    // isUserInteractionEnabled needs to be set to true, otherwise it will not work...
    imageView.isUserInteractionEnabled = true
    imageView.alpha = 0
    imageView.isHidden = !isDragging
    return imageView
  }()

}

extension ViewController: UIDropInteractionDelegate {
  
  func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
    return session.canLoadObjects(ofClass: UIColor.self)
  }
  
  func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
    return UIDropProposal(operation: .copy)
  }
  
  func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
    session.items.forEach({ item in
      if let index = item.localObject as? Int {
      // performBatchUpdates is necessary also to make the changes animate
        collectionView_A.performBatchUpdates {
          colors_A.remove(at: index)
          collectionView_A.deleteItems(at: [IndexPath(item: index, section: 0)])
        }
      }
    })
  }
}
カテゴリー
iOS Swift

UIView.isHiddenをAnimateする

var isDraggingに応じて、とあるview.isHiddenをアニメーションするサンプルコード。

isHiddenだけだとAnimationしないので、alphaをAnimationしています。

var isDragging: Bool = false {
  didSet {
    if isDragging == true {
      UIView.animate(withDuration: 0.5, animations: {
        // make trashImageView.isHidden to false,
        self.trashImageView.isHidden = !self.isDragging
        // then animate the alpha
        self.trashImageView.alpha = 1.0
      }) { _ in
        // do nothing
      }
    } else {
      UIView.animate(withDuration: 0.5, animations: {
        // animate the alpha first,
        self.trashImageView.alpha = 0
      }) { _ in
        // then, making isHidden to true
        self.trashImageView.isHidden = !self.isDragging
      }
    }
  }
}
カテゴリー
iOS Swift

Drag and Dropを学ぶ

参考

【概要】

ドラッグはUIエレメントに対するロングプレスをトリガーとして開始され、プレヴューイメージが表示され、drag session (UIDragSession)が開始される。drag sessionはドラッグ動作が完了もしくはキャンセルされるまで維持される。

プレヴューイメージはDestinationまでドラッグされる。Destinationがドラッグされたアイテムの型を扱える場合はその旨の表示がなされる。

ViewControllerにはdrag delegateやdrop delegateがアサインされる。

ViewController内のドラッグされる個別のviewはinteraction objectsを持つ必要がある。drag source(ドラッグ元)のviewはUIDragInteractionを、drag destination(ドラッグ先)のviewはUIDropInteractionを持つ。これらのInteractionには幅広いレンジのdelegateメソッドがあり、実装に応じてドラッグ・ドロップ・ライフサイクル内の様々な時点で呼ばれる。

各ドラッグ動作はdrag item (UIDragItem)で表される。drag itemはプレヴューイメージやitem provider (UIItemProvider)を持つ。item providerは移動対象のアイテムに関する情報を持ち、それはドロップが実施される場合の非同期トランスファーに使用される。

以下はdrag sourceとdrag destinationの最小コード

import UIKit

class ViewController: UIViewController {

  var stringArray: [String] = [] {
    didSet {
      print(stringArray)
    }
  }
  
  lazy var dragSource: UIView = {
    let view = UIView()
    let dragInteraction = UIDragInteraction(delegate: self)
    view.addInteraction(dragInteraction)
    return view
  }()
  
  lazy var dragDestination: UIView = {
    let view = UIView()
    let dropInteraction = UIDropInteraction(delegate: self)
    view.addInteraction(dropInteraction)
    return view
  }()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    view.backgroundColor = .white
    view.addSubview(dragSource)
    view.addSubview(dragDestination)
  }
  
  override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    layout()
  }
  
  func layout() {
    // dragSource
    let itemSize: CGFloat = 100
    dragSource.frame = CGRect(x: view.bounds.width/2 - itemSize/2, y: view.bounds.height/2 - itemSize/2, width: itemSize, height: itemSize)
    dragSource.backgroundColor = .red
    // dragDestination
    dragDestination.frame = CGRect(x: view.bounds.width/2 - itemSize/2, y: view.bounds.height - itemSize/2 - 200, width: itemSize, height: itemSize)
    dragDestination.backgroundColor = .blue
  }
}

extension ViewController: UIDragInteractionDelegate {
  func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
    let localObject = CustomClass(name: "me")
    let stringItemProvider = NSItemProvider(object: "Hello" as NSString)
    let item = UIDragItem(itemProvider: stringItemProvider)
    item.localObject = localObject
    return [
      item
    ]
  }
}

extension ViewController: UIDropInteractionDelegate {
  func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
    return true
  }
  
  func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: UIDropSession) {
    let dropLocation = session.location(in: view)
    
  }
}

class CustomClass {

  var name: String
  
  init(name: String) {
    self.name = name
  }
  
}
カテゴリー
iOS Swift

UserDefaultsにデータを保存する

// to set data in UserDefaults
UserDefaults.standard.set(Float(0.0), forKey: "some_key")

// to fetch from UserDefaults
UserDefaults.standard.float(forKey: "some_key")
カテゴリー
iOS Swift

UINavigationControllerサンプルコード

globalにNavigationControllerを設定するにはSceneDelegate内で設定をします。

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
    guard let windowScene = (scene as? UIWindowScene) else { return }
    window = UIWindow(frame: windowScene.coordinateSpace.bounds)
    window?.windowScene = windowScene
    let navigationController = UINavigationController(rootViewController: SequenceViewController())
    window?.rootViewController = navigationController
    window?.makeKeyAndVisible()
}

別のスクリーンへ遷移するには以下のようにします。

@objc private func goToVC() {
  let targetVC = NextViewController()
  navigationController?.pushViewController(targetVC, animated: true)
}

iOSのスクリーン遷移は、navigationController.viewControllersという配列にUIViewControllerをスタックする形で管理します。試しに、遷移先のviewDidLoad内でviewControllers配列をprintしてみます。その時点で表示されているViewControllerは配列内の一番最後のものだと分かります。

// print(navigationController?.viewControllers)

Optional([<ChordGenius.ViewController: 0x106817200>, <ChordGenius.ChordSelectVC: 0x105f07bd0>])

ひとつ前のViewControllerに戻るには、popViewController()を使います。なお、popToRootViewController()は一番最初のVCに戻る時に使います。

Transitionの挙動を設定するにはCATransitionを使い、navigationControllerに設定します。

@objc private func transitionButtonTapped() {
    let transition = CATransition()
    transition.duration = 0.5
    transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    transition.type = .fade
    navigationController?.view.layer.add(transition, forKey: nil)
    navigationController?.popToRootViewController(animated: false)
}

個別のviewController内に新しいnavigationControllerインスタンスを作成し、presentすることも出来ます。

@objc private func transitionButtonTapped() {
  let targetVC = BookmarkedViewController()
  let navigationVC = UINavigationController(rootViewController: targetVC)
  navigationVC.modalTransitionStyle = .crossDissolve
  navigationVC.modalPresentationStyle = .fullScreen
  self.present(navigationVC, animated: true)
}

viewDidLoad内でNavigationItemを設定するには以下のようにします。

// some basic examples
title = "Manage Songs"
navigationController?.navigationBar.prefersLargeTitles = true
    self.navigationItem.setRightBarButton(UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addTapped)), animated: true)

余談ですが、UINavigationControllerを設定するとview.safeAreaInsets.topの値が少し大きくなります。

カテゴリー
iOS Swift

SVGをUIButtonのアイコンとして使う

SVGをiOSで使う場合はまずAssetsフォルダにSVGを保存し、以下のように設定します。

こうすることで通常のSF Symbolのアイコンのように tintColorが反映されるようになります。

lazy var gotoSequenceButton: UIButton = {
    let button = UIButton()
    button.setImage(UIImage(named: "play.square.stack"), for: .normal)
    button.imageView?.contentMode = .scaleAspectFit
    button.contentHorizontalAlignment = .fill
    button.contentVerticalAlignment = .fill
    button.tintColor = .white
    button.addTarget(self, action: #selector(onGotoSequenceButtonTapped), for: .touchUpInside)
    return button
}()
カテゴリー
iOS Swift

ViewControllerごとにOrientationを指定したい

iOS15 参考

iOS16以降 参考

Helper

class Helper {
  static func forceRotate(orientation: UIInterfaceOrientationMask) {
    (UIApplication.shared.delegate as? AppDelegate)?.orientation = orientation
  let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

  windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))

UIApplication.navigationTopViewController()?.setNeedsUpdateOfSupportedInterfaceOrientations()
  }
}

extension UIApplication {
  class func navigationTopViewController() -> UIViewController? {
    let nav = UIApplication.shared.keyWindow?.rootViewController as? UINavigationController
    return nav?.topViewController
  }
}

appDelegate

var orientation: UIInterfaceOrientationMask = .portrait
  
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    return orientation
}

viewDidLoad()

override func viewDidLoad() {
  super.viewDidLoad()

  Helper.forceRotate(orientation: .portrait)
}
カテゴリー
iOS Swift

Custom Slider Barを作ってみた

Github Repo

UIViewのoverride func touchesMoved() のtouchesは色々な情報を含んでいますが、その中のtouches: Set<UITouch>を利用しています。

ポイントとしては。。。

scaleBar.layer.anchorPointのイニシャル値はセンター(0.5, 0.5)ですが、(0.5, 1)とすることでshapeの底辺中央をanchorPointに設定できます。

iOSではy値はその自身の左上コーナーを基点とするので、自身のheightからy値を引いたものをyValueとし、scaleBy(x: 1, yValue)でy方向にのみscaleさせています。

0 <= yValue <= height としたいため、max(min(height, touch.location(in: self).y), 0) としています。

RxSwiftのBehaviorRelayを使って、スライダーの値が変化する度にUIなどを変更出来るようにしています。

lazy var scaleBar: UIView = {
    let scaleBar = UIView()
    scaleBar.translatesAutoresizingMaskIntoConstraints = false
    scaleBar.backgroundColor = .blue
    // initial anchorPoint is (0.5, 0.5) and the shape stretches from the center
    scaleBar.layer.anchorPoint = CGPoint(x: 0.5, y: 1)
    return scaleBar
}()

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let yValue = height - max(min(height, touch.location(in: self).y), 0)
    yValueRelay.accept(yValue)
    scaleBar.transform = startTransform?.scaledBy(x: 1, y: yValue) ?? CGAffineTransform.identity
}
カテゴリー
iOS Swift

UITableViewを使ってカスタムPickerViewのようなものを作る

Sample Code

class ViewController: UIViewController {

  let dataCount = 10 // this will be replaced by the datasource count.
  
  var currentIndex: Int = 0 {
    didSet {
      print("aboveButton.isHidden: \(aboveButton.isHidden)")

      if currentIndex == 0 {
        UIView.animate(withDuration: 1, animations: {
          self.aboveButton.alpha = 0
        })
      } else if currentIndex == dataCount - 1 {
        UIView.animate(withDuration: 1, animations: {
          self.belowButton.alpha = 0
        })
      }
      else {
        UIView.animate(withDuration: 0.5, animations: {
          self.aboveButton.alpha = 1
          self.belowButton.alpha = 1
        })
      }
    }
  }
  
  let tableViewHeight: CGFloat = 300
  
  let buttonSize: CGFloat = 50
  
  lazy var aboveButton: UIButton = {
    let button = UIButton()
    button.setImage(UIImage(systemName: "chevron.up"), for: .normal)
    button.imageView?.contentMode = .scaleAspectFit
    button.contentHorizontalAlignment = .fill
    button.contentVerticalAlignment = .fill
    button.tintColor = .systemGray
    button.addTarget(self, action: #selector(onAboveButtonTapped), for: .touchUpInside)
    return button
  }()
  
  @objc private func onAboveButtonTapped() {
    let index = currentIndex - 1
    guard index >= 0 else { return }
    let indexPath = IndexPath(row: index, section: 0)
    tableView.scrollToRow(at: indexPath, at: .top, animated: true)
    currentIndex = index
  }
  
  lazy var belowButton: UIButton = {
    let button = UIButton()
    button.setImage(UIImage(systemName: "chevron.down"), for: .normal)
    button.imageView?.contentMode = .scaleAspectFit
    button.contentHorizontalAlignment = .fill
    button.contentVerticalAlignment = .fill
    button.tintColor = .systemGray
    button.addTarget(self, action: #selector(onBelowButtonTapped), for: .touchUpInside)
    return button
  }()
  
  @objc private func onBelowButtonTapped() {
    let index = currentIndex + 1
    guard index <= dataCount - 1 else { return }
    let indexPath = IndexPath(row: index, section: 0)
    tableView.scrollToRow(at: indexPath, at: .top, animated: true)
    currentIndex = index
  }
  
  lazy var tableView: UITableView = {
    let tableView = UITableView()
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    tableView.separatorColor = .clear
    tableView.rowHeight = tableViewHeight
    return tableView
  }()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    view.backgroundColor = .white
    view.addSubview(tableView)
    view.addSubview(aboveButton)
    view.addSubview(belowButton)
    if currentIndex == 0 {
      aboveButton.alpha = 0
    }
    if currentIndex == dataCount - 1 {
      belowButton.alpha = 0
    }
    tableView.delegate = self
    tableView.dataSource = self
  }
  
  override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    layout()
    
  }
  
  private func layout() {
    tableView.frame = CGRect(x: 0, y: view.bounds.height/2 - tableViewHeight/2, width: view.bounds.width, height: tableViewHeight)
    aboveButton.frame = CGRect(x: view.bounds.width/2 - buttonSize/2, y: tableView.frame.minY + 10, width: buttonSize, height: buttonSize)
    belowButton.frame = CGRect(x: view.bounds.width/2 - buttonSize/2, y: tableView.frame.minY + tableViewHeight - 10 - buttonSize, width: buttonSize, height: buttonSize)
  }

}

extension ViewController: UITableViewDelegate {
  
}

extension ViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    dataCount
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    cell.textLabel?.text = "\(indexPath.row)"
    cell.backgroundColor = .systemGray6
    return cell
  }
  
}

extension ViewController: UIScrollViewDelegate {
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    var visibleRect = CGRect()
    visibleRect.origin = tableView.contentOffset
    visibleRect.size = tableView.bounds.size
    let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
    guard let indexPath = tableView.indexPathForRow(at: visiblePoint) else { return }
    currentIndex = indexPath.row
    tableView.scrollToRow(at: indexPath, at: .top, animated: true)
  }
  
  func scrollViewWillEndDragging(_ scrollView: UIScrollView,
                                 withVelocity velocity: CGPoint,
                                 targetContentOffset: UnsafeMutablePointer<CGPoint>)
  {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
      self.scrollViewDidEndDecelerating(scrollView)
    }
  }
}