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)
}
このStanford Universityの授業が参考になりました。
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)])
}
}
})
}
}
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
}
}
}
}
【概要】
ドラッグは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
}
}
// to set data in UserDefaults
UserDefaults.standard.set(Float(0.0), forKey: "some_key")
// to fetch from UserDefaults
UserDefaults.standard.float(forKey: "some_key")
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の値が少し大きくなります。
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
}()
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)
}
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
}
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)
}
}
}