2021-04-13 13:46:42 +02:00
//
// T h r e a d V i e w M o d e l + D i f f a b l e . s w i f t
// M a s t o d o n
//
// C r e a t e d b y M a i n a s u K C i r n o o n 2 0 2 1 - 4 - 1 2 .
//
import UIKit
import Combine
import CoreData
2021-06-21 10:38:59 +02:00
import CoreDataStack
2022-10-08 07:43:06 +02:00
import MastodonCore
2022-10-10 13:14:52 +02:00
import MastodonUI
2021-06-21 10:38:59 +02:00
import MastodonSDK
2021-04-13 13:46:42 +02:00
extension ThreadViewModel {
2022-01-27 14:23:39 +01:00
@ MainActor
2021-04-13 13:46:42 +02:00
func setupDiffableDataSource (
2022-01-27 14:23:39 +01:00
tableView : UITableView ,
statusTableViewCellDelegate : StatusTableViewCellDelegate
2021-04-13 13:46:42 +02:00
) {
2022-01-27 14:23:39 +01:00
diffableDataSource = StatusSection . diffableDataSource (
tableView : tableView ,
context : context ,
configuration : StatusSection . Configuration (
2022-10-09 14:07:57 +02:00
authContext : authContext ,
2022-01-27 14:23:39 +01:00
statusTableViewCellDelegate : statusTableViewCellDelegate ,
2022-02-15 12:44:45 +01:00
timelineMiddleLoaderTableViewCellDelegate : nil ,
filterContext : . thread ,
activeFilters : context . statusFilterService . $ activeFilters
2022-01-27 14:23:39 +01:00
)
2021-04-13 13:46:42 +02:00
)
2022-01-27 14:23:39 +01:00
// m a k e i n i t i a l s n a p s h o t a n i m a t i o n s m o o t h
var snapshot = NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ( )
2021-04-13 13:46:42 +02:00
snapshot . appendSections ( [ . main ] )
2022-01-27 14:23:39 +01:00
if let root = self . root {
if case let . root ( threadContext ) = root ,
let status = threadContext . status . object ( in : context . managedObjectContext ) ,
status . inReplyToID != nil
{
snapshot . appendItems ( [ . topLoader ] , toSection : . main )
}
snapshot . appendItems ( [ . thread ( root ) ] , toSection : . main )
} else {
2021-04-13 13:46:42 +02:00
}
2022-01-29 10:02:30 +01:00
diffableDataSource ? . apply ( snapshot , animatingDifferences : false )
2021-04-13 13:46:42 +02:00
2022-01-27 14:23:39 +01:00
$ threadContext
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] threadContext in
guard let self = self else { return }
guard let _ = threadContext else {
return
}
self . loadThreadStateMachine . enter ( LoadThreadState . Loading . self )
}
. store ( in : & disposeBag )
2021-04-13 13:46:42 +02:00
Publishers . CombineLatest3 (
2022-01-27 14:23:39 +01:00
$ root ,
mastodonStatusThreadViewModel . $ ancestors ,
mastodonStatusThreadViewModel . $ descendants
2021-04-13 13:46:42 +02:00
)
2022-01-27 14:23:39 +01:00
. throttle ( for : 1 , scheduler : DispatchQueue . main , latest : true )
. sink { [ weak self ] root , ancestors , descendants in
2021-04-13 13:46:42 +02:00
guard let self = self else { return }
guard let diffableDataSource = self . diffableDataSource else { return }
2022-01-27 14:23:39 +01:00
Task { @ MainActor in
let oldSnapshot = diffableDataSource . snapshot ( )
var newSnapshot = NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ( )
newSnapshot . appendSections ( [ . main ] )
2021-04-13 13:46:42 +02:00
2022-01-27 14:23:39 +01:00
// t o p l o a d e r
let _hasReplyTo : Bool ? = try ? await self . context . managedObjectContext . perform {
guard case let . root ( threadContext ) = root else { return nil }
guard let status = threadContext . status . object ( in : self . context . managedObjectContext ) else { return nil }
return status . inReplyToID != nil
2021-04-13 13:46:42 +02:00
}
2022-01-27 14:23:39 +01:00
if let hasReplyTo = _hasReplyTo , hasReplyTo {
let state = self . loadThreadStateMachine . currentState
if state is LoadThreadState . NoMore {
// d o n o t h i n g
} else {
newSnapshot . appendItems ( [ . topLoader ] , toSection : . main )
}
2021-04-13 13:46:42 +02:00
}
2022-01-27 14:23:39 +01:00
// r e p l i e s
newSnapshot . appendItems ( ancestors . reversed ( ) , toSection : . main )
// r o o t
if let root = root {
let item = StatusItem . thread ( root )
newSnapshot . appendItems ( [ item ] , toSection : . main )
}
// l e a f s
newSnapshot . appendItems ( descendants , toSection : . main )
// b o t t o m l o a d e r
if let currentState = self . loadThreadStateMachine . currentState {
switch currentState {
case is LoadThreadState . Initial ,
is LoadThreadState . Loading ,
is LoadThreadState . Fail :
newSnapshot . appendItems ( [ . bottomLoader ] , toSection : . main )
default :
break
}
}
let hasChanges = newSnapshot . itemIdentifiers != oldSnapshot . itemIdentifiers
if ! hasChanges {
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : snapshot not changes " )
2021-04-13 13:46:42 +02:00
return
2022-01-27 14:23:39 +01:00
} else {
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : snapshot has changes " )
2021-04-13 13:46:42 +02:00
}
2022-01-27 14:23:39 +01:00
guard let difference = self . calculateReloadSnapshotDifference (
tableView : tableView ,
oldSnapshot : oldSnapshot ,
newSnapshot : newSnapshot
) else {
await self . updateDataSource ( snapshot : newSnapshot , animatingDifferences : false )
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : applied new snapshot without tweak " )
return
2021-04-13 13:46:42 +02:00
}
2022-01-27 14:23:39 +01:00
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : [Snapshot] oldSnapshot: \( oldSnapshot . itemIdentifiers . debugDescription ) " )
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : [Snapshot] newSnapshot: \( newSnapshot . itemIdentifiers . debugDescription ) " )
await self . updateSnapshotUsingReloadData (
tableView : tableView ,
oldSnapshot : oldSnapshot ,
newSnapshot : newSnapshot ,
difference : difference
)
} // e n d T a s k
2021-04-13 13:46:42 +02:00
}
. store ( in : & disposeBag )
2022-01-27 14:23:39 +01:00
}
}
extension ThreadViewModel {
@ MainActor func updateDataSource (
snapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ,
animatingDifferences : Bool
) async {
diffableDataSource ? . apply ( snapshot , animatingDifferences : animatingDifferences )
}
@ MainActor func updateSnapshotUsingReloadData (
snapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem >
) async {
if #available ( iOS 15.0 , * ) {
await self . diffableDataSource ? . applySnapshotUsingReloadData ( snapshot )
} else {
diffableDataSource ? . applySnapshot ( snapshot , animated : false , completion : nil )
}
2021-04-13 13:46:42 +02:00
}
2022-01-27 14:23:39 +01:00
// S o m e U I t w e a k s t o p r e s e n t r e p l i e s a n d c o n v e r s a t i o n s m o o t h l y
@ MainActor private func updateSnapshotUsingReloadData (
tableView : UITableView ,
oldSnapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ,
newSnapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ,
difference : ThreadViewModel . Difference // < S t a t u s I t e m >
) async {
let replies : [ StatusItem ] = {
newSnapshot . itemIdentifiers . filter { item in
guard case let . thread ( thread ) = item else { return false }
guard case . reply = thread else { return false }
return true
}
} ( )
// a d d i t i o n a l m a r g i n f o r . t o p L o a d e r
let oldTopMargin : CGFloat = {
let marginHeight = TimelineTopLoaderTableViewCell . cellHeight
if oldSnapshot . itemIdentifiers . contains ( . topLoader ) || ! replies . isEmpty {
return marginHeight
}
return . zero
} ( )
await self . updateSnapshotUsingReloadData ( snapshot : newSnapshot )
// n o t e :
// t w e a k t h e c o n t e n t o f f s e t a n d b o t t o m i n s e t
// m a k e t h e t a b l e v i e w s t a b l e w h e n d a t a r e l o a d
// t h e k e y p o i n t i s s e t t h e b o t t o m i n s e t t o m a k e t h e r o o t p a d d i n g w i t h " T o p L o a d e r H e i g h t " t o t o p e d g e
// a n d r e s t o r e t h e " T o p L o a d e r H e i g h t " w h e n b o t t o m i n s e t a d j u s t e d
// s e t b o t t o m i n s e t . M a k e r o o t i t e m p i n t o t o p .
if let item = root . flatMap ( { StatusItem . thread ( $0 ) } ) ,
let index = newSnapshot . indexOfItem ( item ) ,
let cell = tableView . cellForRow ( at : IndexPath ( row : index , section : 0 ) )
{
// a l w a y s s e t b o t t o m i n s e t d u e t o l a z y r e p l y l o a d i n g
// o t h e r w i s e t a b l e V i e w w i l l j u m p w h e n i n s e r t r e p l i e s
let bottomSpacing = tableView . safeAreaLayoutGuide . layoutFrame . height - cell . frame . height - oldTopMargin
let additionalInset = round ( tableView . contentSize . height - cell . frame . maxY )
tableView . contentInset . bottom = max ( 0 , bottomSpacing - additionalInset )
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : content inset bottom: \( tableView . contentInset . bottom ) " )
}
// s e t s c r o l l p o s i t i o n
tableView . scrollToRow ( at : difference . targetIndexPath , at : . top , animated : false )
tableView . contentOffset . y = {
var offset : CGFloat = tableView . contentOffset . y - difference . sourceDistanceToTableViewTopEdge
if tableView . contentInset . bottom != 0.0 {
// n e e d s r e s t o r e t o p m a r g i n i f b o t t o m i n s e t a d j u s t e d
offset += oldTopMargin
}
return offset
} ( )
self . logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : applied new snapshot " )
}
2021-04-13 13:46:42 +02:00
}
extension ThreadViewModel {
2022-01-27 14:23:39 +01:00
struct Difference {
let item : StatusItem
2021-04-13 13:46:42 +02:00
let sourceIndexPath : IndexPath
2022-01-27 14:23:39 +01:00
let sourceDistanceToTableViewTopEdge : CGFloat
2021-04-13 13:46:42 +02:00
let targetIndexPath : IndexPath
}
2022-01-27 14:23:39 +01:00
@ MainActor private func calculateReloadSnapshotDifference (
2021-04-13 13:46:42 +02:00
tableView : UITableView ,
2022-01-27 14:23:39 +01:00
oldSnapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem > ,
newSnapshot : NSDiffableDataSourceSnapshot < StatusSection , StatusItem >
) -> Difference ? {
2021-04-13 13:46:42 +02:00
guard oldSnapshot . numberOfItems != 0 else { return nil }
2022-01-27 14:23:39 +01:00
guard let indexPathsForVisibleRows = tableView . indexPathsForVisibleRows ? . sorted ( ) else { return nil }
// f i n d i n d e x o f t h e f i r s t v i s i b l e i t e m i n b o t h o l d a n d n e w s n a p s h o t
2021-04-13 13:46:42 +02:00
var _index : Int ?
let items = oldSnapshot . itemIdentifiers ( inSection : . main )
for ( i , item ) in items . enumerated ( ) {
2022-01-27 14:23:39 +01:00
guard let indexPath = indexPathsForVisibleRows . first ( where : { $0 . row = = i } ) else { continue }
guard newSnapshot . indexOfItem ( item ) != nil else { continue }
let rectForCell = tableView . rectForRow ( at : indexPath )
let distanceToTableViewTopEdge = tableView . convert ( rectForCell , to : nil ) . origin . y - tableView . safeAreaInsets . top
guard distanceToTableViewTopEdge >= 0 else { continue }
2021-04-13 13:46:42 +02:00
_index = i
break
}
2022-01-27 14:23:39 +01:00
guard let index = _index else { return nil }
2021-04-13 13:46:42 +02:00
let sourceIndexPath = IndexPath ( row : index , section : 0 )
2022-01-27 14:23:39 +01:00
let rectForSourceItemCell = tableView . rectForRow ( at : sourceIndexPath )
2022-07-14 20:46:48 +02:00
let sourceDistanceToTableViewTopEdge : CGFloat = {
if tableView . window != nil {
return tableView . convert ( rectForSourceItemCell , to : nil ) . origin . y - tableView . safeAreaInsets . top
} else {
return rectForSourceItemCell . origin . y - tableView . contentOffset . y - tableView . safeAreaInsets . top
}
} ( )
2022-01-27 14:23:39 +01:00
guard sourceIndexPath . section < oldSnapshot . numberOfSections ,
sourceIndexPath . row < oldSnapshot . numberOfItems ( inSection : oldSnapshot . sectionIdentifiers [ sourceIndexPath . section ] )
else { return nil }
let sectionIdentifier = oldSnapshot . sectionIdentifiers [ sourceIndexPath . section ]
let item = oldSnapshot . itemIdentifiers ( inSection : sectionIdentifier ) [ sourceIndexPath . row ]
guard let targetIndexPathRow = newSnapshot . indexOfItem ( item ) ,
let newSectionIdentifier = newSnapshot . sectionIdentifier ( containingItem : item ) ,
let targetIndexPathSection = newSnapshot . indexOfSection ( newSectionIdentifier )
else { return nil }
let targetIndexPath = IndexPath ( row : targetIndexPathRow , section : targetIndexPathSection )
2021-04-13 13:46:42 +02:00
return Difference (
item : item ,
sourceIndexPath : sourceIndexPath ,
2022-01-27 14:23:39 +01:00
sourceDistanceToTableViewTopEdge : sourceDistanceToTableViewTopEdge ,
targetIndexPath : targetIndexPath
2021-04-13 13:46:42 +02:00
)
}
}