web-dev-qa-db-ja.com

行を変更するとバウンスが発生します

グリッチ

CoreDataNSFetchResultControllerを使用して、データをUITableViewに表示しています。問題が1つあります。新しい行が挿入/移動/削除されると、UITableViewcontentOffSet.yを変更します。ユーザーがにスクロールしたとき。中央では、新しい行が挿入されるとUITableViewがバウンスします。

複製プロジェクト

この動作を再現するための最小限のコードを含むプロジェクトへのこのgithubリンク: https://github.com/Jasperav/FetchResultControllerGlitch (コードも下にあります)

これはグリッチを示しています。私はUITableViewの真ん中に立っており、現在のcontentOffSet.yに関係なく、常に新しい行が挿入されているのを確認しています。

enter image description here

同様の質問

懸念事項

また、begin/endUpdatesではなくperformBatchUpdatesに切り替えてみましたが、うまくいきませんでした。

UITableViewは、行を挿入/削除/移動するときに、それらの行がユーザーに表示されない場合は移動しないでください。私はこのようなものが箱から出してうまくいくはずだと思っています。

最終目標

これは私が最終的に欲しいものです(WhatsAppのチャット画面の複製だけです):

  • ユーザーが新しい行が挿入されている一番上(WhatsAppの場合は一番下)まで完全にスクロールされると、UITableViewは新しく挿入された行をアニメーション化し、現在のcontentOffSet.yを変更する必要があります。
  • ユーザーが完全に上(または新しい行が挿入されている場所によっては下)にスクロールされていない場合、ユーザーに表示されているセルは、新しい行が挿入されたときに跳ね返ってはなりません。これは、アプリケーションのユーザーエクスペリエンスにとって本当に悪いことです。
  • 動的高さセルで機能するはずです。
  • セルを移動/削除するときにもこの動作が見られます。ここにすべてのグリッチの簡単な修正はありますか?

UICollectionViewの方が適している場合は、それで問題ありません。

使用事例

WhatsAppチャット画面を複製しようとしています。 NSFetchResultControllerを使用しているかどうかはわかりませんが、それ以外の最終的な目標は、正確なユーザーエクスペリエンスを提供することです。したがって、セルの挿入、移動、削除、更新は、WhatsAppが行っている方法で行う必要があります。したがって、機能する例の場合:WhatsAppに移動し、機能しない例の場合:プロジェクトをダウンロードします。

コピー&ペーストコード

コード(ViewController.Swift):

import CoreData
import UIKit

class ViewController: UIViewController, NSFetchedResultsControllerDelegate, UITableViewDataSource, UITableViewDelegate {

    let tableView = MyTableView()
    let resultController = ViewController.createResultController()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Initial cells
        for i in 0...40 {
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = randomString(length: i + 1)
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = self.randomString(length: Int.random(in: 10...50))
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        resultController.delegate = self

        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true

        tableView.delegate = self
        tableView.dataSource = self
        tableView.estimatedRowHeight = 75


        try! resultController.performFetch()
    }

    public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .delete:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
        case .move:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .update:
            tableView.moveRow(at: indexPath!, to: newIndexPath!)
        }
    }

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.beginUpdates()
    }

    public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return resultController.fetchedObjects?.count ?? 0
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return CGFloat(resultController.object(at: indexPath).height)
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyTableViewCell

        cell.textLabel?.text = resultController.object(at: indexPath).something

        return cell
    }


    private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
        let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()

        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]

        return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
    }

    func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0...length-1).map{ _ in letters.randomElement()! })
    }
}

class MyTableView: UITableView {
    init() {
        super.init(frame: .zero, style: .plain)

        register(MyTableViewCell.self, forCellReuseIdentifier: "cell")
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MyTableViewCell: UITableViewCell {

}

class CoreDataContext {
    static let persistentContainer: NSPersistentContainer =  {
        let container = NSPersistentContainer(name: "FetchViewControllerGlitch")

        container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
            guard let error = error else {
                return
            }
            fatalError(error.localizedDescription)
        })

        return container
    }()
}
16
J. Doe
   let lastScrollOffset = tableView.contentOffset;
   tableView.beginUpdates();
   tableView.insertRows(at: [newIndexPath!], with: .automatic);
   tableView.endUpdates();
   tableView.layer.removeAllAnimations();
   tableView.setContentOffset(lastScrollOffset, animated: false);
  1. すべてのテーブルセルタイプの推定高さを確立できるように最善を尽くしてください。高さがいくらか動的であっても、これはUITableViewに役立ちます。

  2. スクロール位置を保存し、tableViewを更新してendUpdates()を呼び出した後、コンテンツオフセットをリセットします。

これも確認できます チュートリアル

1
pooja

あなたはこれを上記のプージャの答えの編集で試すことができます、私はあなたのような問題に直面しましたUIView.performWithoutAnimationは私のための問題。それが役立つことを願っています。

 UIView.performWithoutAnimation {

        let lastScrollOffset = tableView.contentOffset;
        tableView.beginUpdates();
        tableView.insertRows(at: [newIndexPath!], with: .automatic);
        tableView.endUpdates();
        tableView.setContentOffset(lastScrollOffset, animated: false); 
    }

[〜#〜]編集[〜#〜]

上記を試すこともできますが、行を挿入する代わりに、テーブルビューでデータの再読み込みを使用できますが、その前に、データソースにフェッチされたデータを追加し、ブロック内の最後のコンテンツオフセットを設定します。

0
Shivam Gaur

これを行う

tableView.bounces = false

そしてそれはうまくいくでしょう

0
Mayank Wadhwa

テーブルビューは複雑な獣です。構成によって動作が異なります。テーブルビューは、行の挿入、更新、削除、および移動時にコンテンツオフセットを調整します。テーブルビューがテーブルビューコントローラ内で使用される場合、scrollviewデリゲートメソッドscrollViewDidScroll(_ :)が呼び出されます。

解決策は、そこでコンテンツオフセット調整を取り消すことです。ただし、これはテーブルビューの意図に反するため、viewDidLayoutSubviews()が呼び出されるまで数回実行する必要があります。したがって、このソリューションは最適ではありませんが、動的な高さセル、セクションヘッダー、セクションフッターで機能し、目標に一致する必要があります。

解決策のために、私はあなたのコードを再構築しました。 ViewControllerは、UIViewControllerではなくUITableViewControllerに基づいています。ソリューションの重要な部分は、プロパティfixUpdateContentOffsetの処理と使用です。

import CoreData
import UIKit

class ViewController: UITableViewController, NSFetchedResultsControllerDelegate {

    let resultController = ViewController.createResultController()

    private var fixUpdateContentOffset: CGPoint?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Initial cells
        for i in 0...40 {
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = randomString(length: i + 1)
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = self.randomString(length: Int.random(in: 10...50))
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        resultController.delegate = self

        tableView.estimatedRowHeight = 75

        try! resultController.performFetch()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        fixUpdateContentOffset = nil
    }

    override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        fixUpdateContentOffset = nil
    }

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if let fixUpdateContentOffset = fixUpdateContentOffset,
            tableView.contentOffset.y.rounded(.toNearestOrAwayFromZero) != fixUpdateContentOffset.y.rounded(.toNearestOrAwayFromZero) {
            tableView.contentOffset = fixUpdateContentOffset
        }
    }

    public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .delete:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
        case .move:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .update:
            tableView.moveRow(at: indexPath!, to: newIndexPath!)
        }
    }

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        fixUpdateContentOffset = tableView.contentOffset
        tableView.beginUpdates()
    }

    public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
        fixUpdateContentOffset = tableView.contentOffset
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return resultController.fetchedObjects?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return CGFloat(resultController.object(at: indexPath).height)
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

        cell.textLabel?.text = resultController.object(at: indexPath).something

        return cell
    }


    private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
        let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()

        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]

        return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
    }

    func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0...length-1).map{ _ in letters.randomElement()! })
    }
}

class CoreDataContext {
    static let persistentContainer: NSPersistentContainer =  {
        let container = NSPersistentContainer(name: "FetchViewControllerGlitch")

        container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
            guard let error = error else {
                return
            }
            fatalError(error.localizedDescription)
        })

        return container
    }()
}
0
Ralf

私はなんとかこれを達成することができました。

  • resultController.delegateを削除して、ユーザーがテーブルビューを下にスクロールした場合、更新の適用を停止します
  • resultController.delegateを再度設定して、ユーザーがテーブルビューの先頭に戻った場合に適用を再開します。
  • 無効時間間の同期差分

欠点はフェッチを無効にすることであり、既存の行の更新または削除も無効にします。これらの変更は、フェッチの再起動後に適用されます。

controller(_:didChange:at:for:newIndexPath:)でcontentOffsetを調整しようとしましたが、まったく機能しませんでした。

コードは次のとおりです。

import CoreData
import UIKit

class ViewController: UIViewController, NSFetchedResultsControllerDelegate, UITableViewDataSource, UITableViewDelegate, UIScrollViewDelegate {

    let tableView = MyTableView()
    let resultController = ViewController.createResultController()
    var needsSync = false

    override func viewDidLoad() {
        super.viewDidLoad()

        // Initial cells
        for i in 0...40 {
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = randomString(length: i + 1)
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
            let x = SomeEntity(context: CoreDataContext.persistentContainer.viewContext)

            x.something = self.randomString(length: Int.random(in: 10...50))
            x.date = Date()
            x.height = Float.random(in: 50...100)
        }

        resultController.delegate = self

        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true

        tableView.delegate = self
        tableView.dataSource = self
        tableView.estimatedRowHeight = 75


        try! resultController.performFetch()
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let threshold = CGFloat(100)
        if scrollView.contentOffset.y > threshold && resultController.delegate != nil {
            resultController.delegate = nil
        }
        if scrollView.contentOffset.y <= threshold && resultController.delegate == nil {
            resultController.delegate = self
            needsSync = true
            try! resultController.performFetch()
        }
    }

    public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .delete:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
        case .move:
            tableView.deleteRows(at: [indexPath!], with: .automatic)
            tableView.insertRows(at: [newIndexPath!], with: .automatic)
        case .update:
            tableView.moveRow(at: indexPath!, to: newIndexPath!)
        }
    }

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        if needsSync {
            tableView.reloadData()
        }
        tableView.beginUpdates()
    }

    public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        if needsSync {
            needsSync = false
        }
        tableView.endUpdates()
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return resultController.fetchedObjects?.count ?? 0
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return CGFloat(resultController.object(at: indexPath).height)
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyTableViewCell

        cell.textLabel?.text = resultController.object(at: indexPath).something

        return cell
    }


    private static func createResultController() -> NSFetchedResultsController<SomeEntity> {
        let fetchRequest: NSFetchRequest<SomeEntity> = SomeEntity.fetchRequest()

        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]

        return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: CoreDataContext.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
    }

    func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0...length-1).map{ _ in letters.randomElement()! })
    }
}

class MyTableView: UITableView {
    init() {
        super.init(frame: .zero, style: .plain)

        register(MyTableViewCell.self, forCellReuseIdentifier: "cell")
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MyTableViewCell: UITableViewCell {

}

class CoreDataContext {
    static let persistentContainer: NSPersistentContainer =  {
        let container = NSPersistentContainer(name: "FetchViewControllerGlitch")

        container.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
            guard let error = error else {
                return
            }
            fatalError(error.localizedDescription)
        })

        return container
    }()
}
0
taka

ステップ1:「動かない」とはどういう意味かを定義します。人間にとって、それがジャンプしていることは非常に明白です。しかし、コンピューターはcontentOffsetが同じままであることを認識します。したがって、非常に正確に、上部が表示されている最初のセルは、変更後も正確にその場所にとどまる必要があると定義しましょう。他のすべてのセルは動き回ることができますが、これが私たちのアンカーです。

var somethingIdOfAnchorPoint:String?
var offsetAnchorPoint:CGFloat?

func findHighestCellThatStartsInFrame() -> UITableViewCell? {
  var anchorCell:UITableViewCell?
  for cell in self.tableView.visibleCells {
    let topIsInFrame = cell.frame.Origin.y >= self.tableView.contentOffset.y
    if topIsInFrame {

      if let currentlySelected = anchorCell{
        let isHigerUpInView = cell.frame.Origin.y < currentlySelected.frame.Origin.y
        if  isHigerUpInView {
          anchorCell = cell
        }
      }else{
        anchorCell = cell

      }
    }
  }
  return anchorCell
}

func setAnchorPoint() {
  self.somethingIdOfAnchorPoint = nil;
  self.offsetAnchorPoint = nil;

  if let cell = self.findHighestCellThatStartsInFrame() {
    self.offsetAnchorPoint = cell.frame.Origin.y - self.tableView.contentOffset.y
    if let indexPath = self.tableView.indexPath(for: cell) {
      self.somethingIdOfAnchorPoint = resultController.object(at: indexPath).something
    }
  }
}

setAnchorPointを呼び出すと、どのエンティティ(indexPathではなく、間もなく変更される可能性があるため)が上部に近く、上部からどれだけ離れているかを正確に見つけて記憶します。

次に、変更が発生する直前にsetAnchorPointを呼び出します。

 func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
      self.setAnchorPoint()
      tableView.beginUpdates()
  }

そして、変更が行われた後、アニメーションがないと思われる場所にスクロールして戻ります。

public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.endUpdates()
    self.tableView.layoutSubviews()
    self.scrollToAnchorPoint()
}

func scrollToAnchorPoint() {
  if let somethingId = somethingIdOfAnchorPoint, let offset = offsetAnchorPoint {
    if let item = resultController.fetchedObjects?.first(where: { $0.something == somethingId }),
      let indexPath = resultController.indexPath(forObject: item) {
        let rect = self.tableView.rectForRow(at: indexPath)
        let contentOffset = rect.Origin.y - offset
        self.tableView.setContentOffset(CGPoint.init(x: 0, y: contentOffset), animated: false)
    }
  }
}

そしてそれだけです!ビューが完全に上にスクロールされた場合、これは何もしませんが、その場合は自分で処理できると信じています。

0
Jon Rose