CoreData
とNSFetchResultController
を使用して、データをUITableView
に表示しています。問題が1つあります。新しい行が挿入/移動/削除されると、UITableView
がcontentOffSet.y
を変更します。ユーザーがにスクロールしたとき。中央では、新しい行が挿入されるとUITableView
がバウンスします。
この動作を再現するための最小限のコードを含むプロジェクトへのこのgithubリンク: https://github.com/Jasperav/FetchResultControllerGlitch (コードも下にあります)
これはグリッチを示しています。私はUITableView
の真ん中に立っており、現在のcontentOffSet.y
に関係なく、常に新しい行が挿入されているのを確認しています。
NSFetchedResultsControllerが新しいレコードを追加するときにUITableViewが上にスクロールしないようにするには?rowHeight
とestimatedRowHeight
を明示的に設定したため、関係ありません。
エラー:UITableViewがUITableViewAutomaticDimensionでトップにジャンプします 運がなくてendUpdates
の前にこれを試しました
ITableViewAutomaticDimensionを備えたFetchedResultsControllerを搭載したUITableView-テーブルがリロードされるとセルが移動します 最初のリンクと同じように、rowHeight
とestimatedRowHeight
を設定しました。
また、begin/endUpdates
ではなくperformBatchUpdates
に切り替えてみましたが、うまくいきませんでした。
UITableView
は、行を挿入/削除/移動するときに、それらの行がユーザーに表示されない場合は移動しないでください。私はこのようなものが箱から出してうまくいくはずだと思っています。
これは私が最終的に欲しいものです(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
}()
}
let lastScrollOffset = tableView.contentOffset;
tableView.beginUpdates();
tableView.insertRows(at: [newIndexPath!], with: .automatic);
tableView.endUpdates();
tableView.layer.removeAllAnimations();
tableView.setContentOffset(lastScrollOffset, animated: false);
すべてのテーブルセルタイプの推定高さを確立できるように最善を尽くしてください。高さがいくらか動的であっても、これはUITableViewに役立ちます。
スクロール位置を保存し、tableViewを更新してendUpdates()を呼び出した後、コンテンツオフセットをリセットします。
これも確認できます チュートリアル
あなたはこれを上記のプージャの答えの編集で試すことができます、私はあなたのような問題に直面しましたUIView.performWithoutAnimationは私のための問題。それが役立つことを願っています。
UIView.performWithoutAnimation {
let lastScrollOffset = tableView.contentOffset;
tableView.beginUpdates();
tableView.insertRows(at: [newIndexPath!], with: .automatic);
tableView.endUpdates();
tableView.setContentOffset(lastScrollOffset, animated: false);
}
[〜#〜]編集[〜#〜]
上記を試すこともできますが、行を挿入する代わりに、テーブルビューでデータの再読み込みを使用できますが、その前に、データソースにフェッチされたデータを追加し、ブロック内の最後のコンテンツオフセットを設定します。
これを行う
tableView.bounces = false
そしてそれはうまくいくでしょう
テーブルビューは複雑な獣です。構成によって動作が異なります。テーブルビューは、行の挿入、更新、削除、および移動時にコンテンツオフセットを調整します。テーブルビューがテーブルビューコントローラ内で使用される場合、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
}()
}
私はなんとかこれを達成することができました。
欠点はフェッチを無効にすることであり、既存の行の更新または削除も無効にします。これらの変更は、フェッチの再起動後に適用されます。
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
}()
}
ステップ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)
}
}
}
そしてそれだけです!ビューが完全に上にスクロールされた場合、これは何もしませんが、その場合は自分で処理できると信じています。