Clear View-related references in Fragments and use viewLifecycleOwner when applicable (#4470)

This pull request has main 2 goals related to improving the handling of
View lifecycles in Fragments:

- **Use viewLifecycleOwner when applicable**: every coroutine touching
Views in Fragments must be launched in the `coroutinescope` of
`viewLifecycleOwner` to avoid the following issues:
1. The code will crash if it references a View binding that is no more
available and keeps running after the Fragment view hierarchy has been
destroyed.
2. The code will leak Views if it references Views from its parent scope
after the Fragment view hierarchy has been destroyed.
3. Multiple instances of the same coroutine will run at the same time,
if the coroutine is launched in `onViewCreated()` from the wrong scope
and the view hierarchy is destroyed then re-created in the same Fragment
instance.
- **Clear View-related references in Fragments**: it is an error to keep
a reference to Views or any other class containing View references in
Fragments after `onDestroyView()`. It creates a memory leak when the
Fragment is added to the back stack or is temporarily detached. A
typical object that leaks Views is a RecyclerView's Adapter: when the
adapter is set on the RecyclerView, the RecyclerView registers itself as
a listener to the Adapter and the Adapter now contains a reference to
the RecyclerView that is not automatically cleared. It is thus
recommended to clear all these view references one way or another, even
if the Fragment is currently not part of a scenario where it is detached
or added to a back stack.

In general, having a `lateinit var` not related to Dagger dependency
injection in a Fragment is a code smell and should be avoided:
- If that `lateinit var` is related to storing something View-related,
it must be removed if possible or made nullable and set to `null` in
`onDestroyView()`.
- If that `lateinit var` is meant to store fragment arguments, it can be
turned into a `val by lazy{}`.
- If that `lateinit var` is related to fetching some information from
the Activity, it can be turned into a `val` with no backing field that
will simply call the activity when accessed. There is no need to store
the value in the Fragment.

When possible, View-related values must not be stored as Fragment
fields: all views should be accessed only in `onViewCreated()` and
passed as arguments to various listeners down the chain.

However, it's still required to use nullable fields **when the Fragment
exposes public methods that are called directly by an external entity**,
and these methods use the View-related value. Since the Fragment has no
control over when the external entity will call these public methods,
the field must never assumed to be non-null and null checks must be
added for every call. Note that exposing public methods on a Fragment to
be called directly is an antipattern, but switching to a different
architecture is out of scope of this pull request.

- Use `viewLifecycleOwner` in Fragments where applicable.
- Remove view-related fields and instead declare them in
`onViewCreated()` when possible.
- When not possible, declare view-related fields as nullable and set
them to `null` in `onDestroyView()`.
- Pass non-null View-related fields as arguments when possible, to not
rely on the nullable Fragment field.
- Replace `lateinit var` containing an Activity-related value with `val`
accessing the Activity directly on demand.
- Remove some unused fragment fields.
- Replace `onCreateView()` with passing the layout id as Fragment
constructor argument when possible.
- Replace `isAdded` checks with `view != null`. A Fragment being added
to an Activity doesn't mean it has a View hierarchy (it may be detached
and invisible).
- Remove `mediaPlayerListener` field in `ViewVideoFragment` and turn it
into a local variable. It is then passed into a
`DefaultLifecycleObserver` that replaces the `onStart()`/`onStop()`
overrides and is unregistered automatically when the view hierarchy is
destroyed.
This commit is contained in:
Christophe Beyls 2024-05-31 13:28:01 +02:00 committed by GitHub
commit 83403ebb58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 369 additions and 349 deletions

View file

@ -49,7 +49,7 @@ import kotlinx.coroutines.launch
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
@AndroidEntryPoint
class AccountsInListFragment : DialogFragment() {
class AccountsInListFragment : DialogFragment(R.layout.fragment_accounts_in_list) {
@Inject
lateinit var preferences: SharedPreferences
@ -59,8 +59,6 @@ class AccountsInListFragment : DialogFragment() {
private lateinit var listId: String
private lateinit var listName: String
private val adapter = Adapter()
private val searchAdapter = SearchAdapter()
private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
@ -85,15 +83,10 @@ class AccountsInListFragment : DialogFragment() {
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = Adapter()
val searchAdapter = SearchAdapter()
binding.accountsRecycler.layoutManager = LinearLayoutManager(view.context)
binding.accountsRecycler.adapter = adapter
@ -109,7 +102,7 @@ class AccountsInListFragment : DialogFragment() {
onFailure = { handleError(it) }
)
setupSearchView(state)
setupSearchView(searchAdapter, state)
}
}
@ -130,7 +123,7 @@ class AccountsInListFragment : DialogFragment() {
})
}
private fun setupSearchView(state: State) {
private fun setupSearchView(searchAdapter: SearchAdapter, state: State) {
if (state.searchResult == null) {
searchAdapter.submitList(listOf())
binding.accountsSearchRecycler.hide()

View file

@ -56,13 +56,6 @@ class ListSelectionFragment : DialogFragment() {
private val viewModel: ListsForAccountViewModel by viewModels()
private var _binding: FragmentListsListBinding? = null
// This property is only valid between onCreateDialog and onDestroyView
private val binding get() = _binding!!
private val adapter = Adapter()
private var selectListener: ListSelectionListener? = null
private var accountId: String? = null
@ -80,7 +73,8 @@ class ListSelectionFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
_binding = FragmentListsListBinding.inflate(layoutInflater)
val binding = FragmentListsListBinding.inflate(layoutInflater)
val adapter = Adapter()
binding.listsView.adapter = adapter
val dialogBuilder = AlertDialog.Builder(context)
@ -120,7 +114,7 @@ class ListSelectionFragment : DialogFragment() {
binding.listsView.hide()
binding.messageView.apply {
show()
setup(error) { load() }
setup(error) { load(binding) }
}
}
}
@ -155,7 +149,7 @@ class ListSelectionFragment : DialogFragment() {
}
lifecycleScope.launch {
load()
load(binding)
}
return dialog
@ -173,12 +167,7 @@ class ListSelectionFragment : DialogFragment() {
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun load() {
private fun load(binding: FragmentListsListBinding) {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()

View file

@ -71,7 +71,7 @@ class AccountMediaFragment :
private val viewModel: AccountMediaViewModel by viewModels()
private lateinit var adapter: AccountMediaGridAdapter
private var adapter: AccountMediaGridAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -83,11 +83,12 @@ class AccountMediaFragment :
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
val adapter = AccountMediaGridAdapter(
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick
)
this.adapter = adapter
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val imageSpacing = view.context.resources.getDimensionPixelSize(
@ -145,6 +146,12 @@ class AccountMediaFragment :
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_account_media, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -206,7 +213,7 @@ class AccountMediaFragment :
}
override fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
companion object {

View file

@ -81,8 +81,7 @@ class AccountListFragment :
private lateinit var type: Type
private var id: String? = null
private lateinit var scrollListener: EndlessOnScrollListener
private lateinit var adapter: AccountAdapter<*>
private var adapter: AccountAdapter<*>? = null
private var fetching = false
private var bottomId: String? = null
@ -101,16 +100,13 @@ class AccountListFragment :
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
)
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
val animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
val adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
@ -125,22 +121,32 @@ class AccountListFragment :
}
else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
}
this.adapter = adapter
if (binding.recyclerView.adapter == null) {
binding.recyclerView.adapter = adapter
}
scrollListener = object : EndlessOnScrollListener(layoutManager) {
val scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
}
fetchAccounts(bottomId)
fetchAccounts(adapter, bottomId)
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
fetchAccounts()
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
fetchAccounts(adapter)
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onViewTag(tag: String) {
@ -303,7 +309,7 @@ class AccountListFragment :
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private fun fetchAccounts(fromId: String? = null) {
private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) {
if (fetching) {
return
}
@ -319,19 +325,19 @@ class AccountListFragment :
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val accountList = response.body()
if (accountList == null) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
onFetchAccountsSuccess(adapter, accountList, linkHeader)
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
@ -339,12 +345,16 @@ class AccountListFragment :
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(exception)
onFetchAccountsFailure(adapter, exception)
}
}
}
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
private fun onFetchAccountsSuccess(
adapter: AccountAdapter<*>,
accounts: List<TimelineAccount>,
linkHeader: String?
) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
@ -359,7 +369,7 @@ class AccountListFragment :
}
if (adapter is MutesAdapter) {
fetchRelationships(accounts.map { it.id })
fetchRelationships(adapter, accounts.map { it.id })
}
bottomId = fromId
@ -378,23 +388,30 @@ class AccountListFragment :
}
}
private fun fetchRelationships(ids: List<String>) {
lifecycleScope.launch {
private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
api.relationships(ids)
.fold(::onFetchRelationshipsSuccess) { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
.fold(
onSuccess = { relationships ->
onFetchRelationshipsSuccess(mutesAdapter, relationships)
},
onFailure = { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
)
}
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
val mutesAdapter = adapter as MutesAdapter
private fun onFetchRelationshipsSuccess(
mutesAdapter: MutesAdapter,
relationships: List<Relationship>
) {
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchAccountsFailure(throwable: Throwable) {
private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
@ -403,7 +420,7 @@ class AccountListFragment :
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(null)
this.fetchAccounts(adapter, null)
}
}
}

View file

@ -17,12 +17,10 @@ package com.keylesspalace.tusky.components.conversation
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
@ -71,7 +69,7 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class ConversationsFragment :
SFragment(),
SFragment(R.layout.fragment_timeline),
StatusActionListener,
ReselectableFragment,
MenuProvider {
@ -86,18 +84,10 @@ class ConversationsFragment :
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter
private var adapter: ConversationAdapter? = null
private var hideFab = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
@ -117,9 +107,10 @@ class ConversationsFragment :
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = ConversationAdapter(statusDisplayOptions, this)
val adapter = ConversationAdapter(statusDisplayOptions, this)
this.adapter = adapter
setupRecyclerView()
setupRecyclerView(adapter)
initSwipeToRefresh()
@ -132,7 +123,7 @@ class ConversationsFragment :
binding.progressBar.hide()
if (loadState.isAnyLoading()) {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.dispatch(
ConversationsLoadingEvent(
accountManager.activeAccount?.accountId ?: ""
@ -219,15 +210,21 @@ class ConversationsFragment :
}
}
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(adapter, event.preferenceKey)
}
}
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_conversations, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -250,7 +247,7 @@ class ConversationsFragment :
}
}
private fun setupRecyclerView() {
private fun setupRecyclerView(adapter: ConversationAdapter) {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -265,7 +262,7 @@ class ConversationsFragment :
}
private fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
private fun initSwipeToRefresh() {
@ -278,13 +275,13 @@ class ConversationsFragment :
}
override fun onFavourite(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
@ -292,7 +289,7 @@ class ConversationsFragment :
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
override fun onMore(view: View, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
@ -316,7 +313,7 @@ class ConversationsFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewMedia(
attachmentIndex,
AttachmentViewData.list(conversation.lastStatus),
@ -326,7 +323,7 @@ class ConversationsFragment :
}
override fun onViewThread(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
}
}
@ -336,13 +333,13 @@ class ConversationsFragment :
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
@ -352,7 +349,7 @@ class ConversationsFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
@ -372,13 +369,13 @@ class ConversationsFragment :
}
override fun onReply(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
reply(conversation.lastStatus.status)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
@ -387,7 +384,7 @@ class ConversationsFragment :
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
@ -407,7 +404,7 @@ class ConversationsFragment :
.show()
}
private fun onPreferenceChanged(key: String) {
private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) {
when (key) {
PrefKeys.FAB_HIDE -> {
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)

View file

@ -43,7 +43,7 @@ class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks) {
}
}
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.domainPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}

View file

@ -80,7 +80,7 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class NotificationsFragment :
SFragment(),
SFragment(R.layout.fragment_timeline_notifications),
SwipeRefreshLayout.OnRefreshListener,
StatusActionListener,
NotificationActionListener,
@ -98,7 +98,7 @@ class NotificationsFragment :
private val viewModel: NotificationsViewModel by viewModels()
private lateinit var adapter: NotificationsPagingAdapter
private var adapter: NotificationsPagingAdapter? = null
private var hideFab: Boolean = false
private var showNotificationsFilterBar: Boolean = true
@ -108,14 +108,6 @@ class NotificationsFragment :
private var loadMorePosition: Int? = null
private var statusIdBelowLoadMore: String? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
@ -151,13 +143,14 @@ class NotificationsFragment :
// Setup the RecyclerView.
binding.recyclerView.setHasFixedSize(true)
adapter = NotificationsPagingAdapter(
val adapter = NotificationsPagingAdapter(
accountId = accountManager.activeAccount!!.accountId,
statusListener = this,
notificationActionListener = this,
accountActionListener = this,
statusDisplayOptions = statusDisplayOptions
)
this.adapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(
@ -247,7 +240,7 @@ class NotificationsFragment :
}
}
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
updateReadingPositionForOldestFirst()
updateReadingPositionForOldestFirst(adapter)
}
}
})
@ -261,7 +254,7 @@ class NotificationsFragment :
viewLifecycleOwner.lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(adapter, event.preferenceKey)
}
}
}
@ -285,15 +278,21 @@ class NotificationsFragment :
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}
override fun onRefresh() {
adapter.refresh()
adapter?.refresh()
}
override fun onViewAccount(id: String) {
@ -309,7 +308,7 @@ class NotificationsFragment :
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
val notification = adapter.peek(position) ?: return
val notification = adapter?.peek(position) ?: return
viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
}
@ -324,17 +323,17 @@ class NotificationsFragment :
}
override fun onReply(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
}
override fun removeItem(position: Int) {
val notification = adapter.peek(position) ?: return
val notification = adapter?.peek(position) ?: return
viewModel.remove(notification.id)
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
}
@ -348,8 +347,8 @@ class NotificationsFragment :
}
private fun onTranslate(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
lifecycleScope.launch {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status)
.onFailure {
Snackbar.make(
@ -362,32 +361,32 @@ class NotificationsFragment :
}
override fun onUntranslate(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.bookmark(bookmark, status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status)
}
override fun clearWarningAction(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status)
}
override fun onMore(view: View, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.more(
status.status,
view,
@ -397,32 +396,33 @@ class NotificationsFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
}
override fun onViewThread(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return
val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return
super.viewThread(status.id, status.url)
}
override fun onOpenReblog(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onLoadMore(position: Int) {
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
val adapter = this.adapter
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position
statusIdBelowLoadMore =
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
@ -430,7 +430,7 @@ class NotificationsFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
@ -478,7 +478,7 @@ class NotificationsFragment :
window.showAsDropDown(binding.buttonFilter)
}
private fun onPreferenceChanged(key: String) {
private fun onPreferenceChanged(adapter: NotificationsPagingAdapter, key: String) {
when (key) {
PrefKeys.FAB_HIDE -> {
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
@ -493,7 +493,7 @@ class NotificationsFragment :
}
PrefKeys.SHOW_NOTIFICATIONS_FILTER -> {
if (isAdded) {
if (view != null) {
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
updateFilterBarVisibility()
}
@ -522,7 +522,7 @@ class NotificationsFragment :
}
}
private fun updateReadingPositionForOldestFirst() {
private fun updateReadingPositionForOldestFirst(adapter: NotificationsPagingAdapter) {
var position = loadMorePosition ?: return
val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return

View file

@ -80,7 +80,7 @@ class ReportStatusesFragment :
private val binding by viewBinding(FragmentReportStatusesBinding::bind)
private lateinit var adapter: StatusesAdapter
private var adapter: StatusesAdapter? = null
private var snackbarErrorRetry: Snackbar? = null
@ -114,6 +114,13 @@ class ReportStatusesFragment :
setupSwipeRefreshLayout()
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
snackbarErrorRetry = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_report_statuses, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -137,7 +144,8 @@ class ReportStatusesFragment :
override fun onRefresh() {
snackbarErrorRetry?.dismiss()
adapter.refresh()
snackbarErrorRetry = null
adapter?.refresh()
}
private fun setupSwipeRefreshLayout() {
@ -163,7 +171,8 @@ class ReportStatusesFragment :
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this)
val adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this)
this.adapter = adapter
binding.recyclerView.addItemDecoration(
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)
@ -172,7 +181,7 @@ class ReportStatusesFragment :
binding.recyclerView.adapter = adapter
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.statusesFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
@ -183,7 +192,7 @@ class ReportStatusesFragment :
loadState.append is LoadState.Error ||
loadState.prepend is LoadState.Error
) {
showError()
showError(adapter)
}
binding.progressBarBottom.visible(loadState.append == LoadState.Loading)
@ -198,13 +207,15 @@ class ReportStatusesFragment :
}
}
private fun showError() {
private fun showError(adapter: StatusesAdapter) {
if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) {
adapter.retry()
}
snackbarErrorRetry?.show()
snackbarErrorRetry =
Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.action_retry) {
adapter.retry()
}.also {
it.show()
}
}
}

View file

@ -56,15 +56,22 @@ abstract class SearchFragment<T : Any> :
abstract fun createAdapter(): PagingDataAdapter<T, *>
abstract val data: Flow<PagingData<T>>
protected lateinit var adapter: PagingDataAdapter<T, *>
protected var adapter: PagingDataAdapter<T, *>? = null
private var currentQuery: String = ""
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initAdapter()
val adapter = initAdapter()
setupSwipeRefreshLayout()
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
subscribeObservables()
subscribeObservables(adapter)
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
snackbarErrorRetry = null
super.onDestroyView()
}
private fun setupSwipeRefreshLayout() {
@ -72,7 +79,7 @@ abstract class SearchFragment<T : Any> :
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun subscribeObservables() {
private fun subscribeObservables(adapter: PagingDataAdapter<T, *>) {
viewLifecycleOwner.lifecycleScope.launch {
data.collectLatest { pagingData ->
adapter.submitData(pagingData)
@ -82,7 +89,7 @@ abstract class SearchFragment<T : Any> :
adapter.addLoadStateListener { loadState ->
if (loadState.refresh is LoadState.Error) {
showError()
showError(adapter)
}
val isNewSearch = currentQuery != viewModel.currentQuery
@ -128,22 +135,26 @@ abstract class SearchFragment<T : Any> :
}
}
private fun initAdapter() {
private fun initAdapter(): PagingDataAdapter<T, *> {
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
adapter = createAdapter()
val adapter = createAdapter()
this.adapter = adapter
binding.searchRecyclerView.adapter = adapter
binding.searchRecyclerView.setHasFixedSize(true)
(binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
return adapter
}
private fun showError() {
private fun showError(adapter: PagingDataAdapter<T, *>) {
if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) {
snackbarErrorRetry = null
adapter.retry()
}
snackbarErrorRetry?.show()
snackbarErrorRetry =
Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.action_retry) {
snackbarErrorRetry = null
adapter.retry()
}.also {
it.show()
}
}
}
@ -167,6 +178,8 @@ abstract class SearchFragment<T : Any> :
get() = (activity as? BottomSheetActivity)
override fun onRefresh() {
adapter.refresh()
snackbarErrorRetry?.dismiss()
snackbarErrorRetry = null
adapter?.refresh()
}
}

View file

@ -82,9 +82,6 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
override val data: Flow<PagingData<StatusViewData.Concrete>>
get() = viewModel.statusesFlow
private val searchAdapter
get() = super.adapter as SearchStatusesAdapter
private var pendingMediaDownloads: List<String>? = null
private val downloadAllMediaPermissionLauncher =
@ -129,6 +126,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
val adapter = SearchStatusesAdapter(statusDisplayOptions, this)
binding.searchRecyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.searchRecyclerView, this) { pos ->
@ -148,41 +146,41 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
)
binding.searchRecyclerView.layoutManager =
LinearLayoutManager(binding.searchRecyclerView.context)
return SearchStatusesAdapter(statusDisplayOptions, this)
return adapter
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.contentHiddenChange(it, isShowing)
}
}
override fun onReply(position: Int) {
searchAdapter.peek(position)?.let { status ->
adapter?.peek(position)?.let { status ->
reply(status)
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.peek(position)?.let { status ->
adapter?.peek(position)?.let { status ->
viewModel.favorite(status, favourite)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.peek(position)?.let { status ->
adapter?.peek(position)?.let { status ->
viewModel.bookmark(status, bookmark)
}
}
override fun onMore(view: View, position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
more(it, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
searchAdapter.peek(position)?.let { status ->
adapter?.peek(position)?.let { status ->
when (status.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(status)
@ -213,20 +211,20 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
override fun onViewThread(position: Int) {
searchAdapter.peek(position)?.status?.let { status ->
adapter?.peek(position)?.status?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.peek(position)?.status?.let { status ->
adapter?.peek(position)?.status?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
}
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.expandedChange(it, expanded)
}
}
@ -236,13 +234,13 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.collapsedChange(it, isCollapsed)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.voteInPoll(it, choices)
}
}
@ -250,19 +248,19 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
override fun clearWarningAction(position: Int) {}
private fun removeItem(position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.removeItem(it)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.peek(position)?.let { status ->
adapter?.peek(position)?.let { status ->
viewModel.reblog(status, reblog)
}
}
override fun onUntranslate(position: Int) {
searchAdapter.peek(position)?.let {
adapter?.peek(position)?.let {
viewModel.untranslate(it)
}
}
@ -418,7 +416,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
R.id.status_mute_conversation -> {
searchAdapter.peek(position)?.let { foundStatus ->
adapter?.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, !status.muted)
}
return@setOnMenuItemClickListener true
@ -460,7 +458,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
R.id.status_edit -> {
editStatus(id, position, status)
editStatus(id, status)
return@setOnMenuItemClickListener true
}
@ -473,7 +471,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
if (statusViewData.translation != null) {
viewModel.untranslate(statusViewData)
} else {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(statusViewData)
.onFailure {
Snackbar.make(
@ -574,11 +572,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
activity?.let {
AlertDialog.Builder(it)
context?.let { context ->
AlertDialog.Builder(context)
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.deleteStatusAsync(id).await().fold(
{ deletedStatus ->
removeItem(position)
@ -590,7 +588,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
val intent = ComposeActivity.startIntent(
requireContext(),
context,
ComposeOptions(
content = redraftStatus.text.orEmpty(),
inReplyToId = redraftStatus.inReplyToId,
@ -621,8 +619,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
}
private fun editStatus(id: String, position: Int, status: Status) {
lifecycleScope.launch {
private fun editStatus(id: String, status: Status) {
viewLifecycleOwner.lifecycleScope.launch {
mastodonApi.statusSource(id).fold(
{ source ->
val composeOptions = ComposeOptions(

View file

@ -18,14 +18,12 @@ package com.keylesspalace.tusky.components.timeline
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
@ -82,7 +80,7 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class TimelineFragment :
SFragment(),
SFragment(R.layout.fragment_timeline),
OnRefreshListener,
StatusActionListener,
ReselectableFragment,
@ -108,7 +106,7 @@ class TimelineFragment :
private lateinit var kind: TimelineViewModel.Kind
private lateinit var adapter: TimelinePagingAdapter
private var adapter: TimelinePagingAdapter? = null
private var isSwipeToRefreshEnabled = true
private var hideFab = false
@ -174,7 +172,9 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
}
private fun createAdapter(): TimelinePagingAdapter {
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -198,25 +198,20 @@ class TimelineFragment :
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = TimelinePagingAdapter(
return TimelinePagingAdapter(
statusDisplayOptions,
this
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val adapter = createAdapter()
this.adapter = adapter
setupSwipeRefreshLayout()
setupRecyclerView()
setupRecyclerView(adapter)
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
@ -272,7 +267,7 @@ class TimelineFragment :
}
}
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
updateReadingPositionForOldestFirst()
updateReadingPositionForOldestFirst(adapter)
}
}
})
@ -307,12 +302,12 @@ class TimelineFragment :
eventHub.events.collect { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(adapter, event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
handleStatusComposeEvent(adapter, status)
}
}
}
@ -327,6 +322,12 @@ class TimelineFragment :
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
if (isSwipeToRefreshEnabled) {
menuInflater.inflate(R.menu.fragment_timeline, menu)
@ -365,7 +366,7 @@ class TimelineFragment :
// match the adapter position where data was inserted (which is why loadMorePosition
// is tracked manually, see this bug report for another example:
// https://github.com/android/architecture-components-samples/issues/726).
private fun updateReadingPositionForOldestFirst() {
private fun updateReadingPositionForOldestFirst(adapter: TimelinePagingAdapter) {
var position = loadMorePosition ?: return
val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return
@ -394,7 +395,7 @@ class TimelineFragment :
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupRecyclerView() {
private fun setupRecyclerView(adapter: TimelinePagingAdapter) {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
if (pos in 0 until adapter.itemCount) {
@ -417,7 +418,7 @@ class TimelineFragment :
override fun onRefresh() {
binding.statusView.hide()
adapter.refresh()
adapter?.refresh()
}
override val onMoreTranslate =
@ -432,18 +433,18 @@ class TimelineFragment :
}
override fun onReply(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
}
private fun onTranslate(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
lifecycleScope.launch {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status)
.onFailure {
Snackbar.make(
@ -456,32 +457,32 @@ class TimelineFragment :
}
override fun onUntranslate(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.bookmark(bookmark, status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status)
}
override fun clearWarningAction(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status)
}
override fun onMore(view: View, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.more(
status.status,
view,
@ -491,34 +492,35 @@ class TimelineFragment :
}
override fun onOpenReblog(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onShowReblogs(position: Int) {
val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return
val statusId = adapter?.peek(position)?.asStatusOrNull()?.id ?: return
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
activity?.startActivityWithSlideInAnimation(intent)
}
override fun onShowFavs(position: Int) {
val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return
val statusId = adapter?.peek(position)?.asStatusOrNull()?.id ?: return
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
activity?.startActivityWithSlideInAnimation(intent)
}
override fun onLoadMore(position: Int) {
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
val adapter = this.adapter
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position
statusIdBelowLoadMore =
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
@ -526,12 +528,12 @@ class TimelineFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.viewMedia(
attachmentIndex,
AttachmentViewData.list(status),
@ -540,7 +542,7 @@ class TimelineFragment :
}
override fun onViewThread(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
super.viewThread(status.actionable.id, status.actionable.url)
}
@ -568,7 +570,7 @@ class TimelineFragment :
super.viewAccount(id)
}
private fun onPreferenceChanged(key: String) {
private fun onPreferenceChanged(adapter: TimelinePagingAdapter, key: String) {
when (key) {
PrefKeys.FAB_HIDE -> {
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
@ -591,7 +593,7 @@ class TimelineFragment :
}
}
private fun handleStatusComposeEvent(status: Status) {
private fun handleStatusComposeEvent(adapter: TimelinePagingAdapter, status: Status) {
when (kind) {
TimelineViewModel.Kind.HOME,
TimelineViewModel.Kind.PUBLIC_FEDERATED,
@ -612,7 +614,7 @@ class TimelineFragment :
}
public override fun removeItem(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.removeStatusWithId(status.id)
}
@ -630,7 +632,7 @@ class TimelineFragment :
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()
?.let { position ->
if (position != RecyclerView.NO_POSITION) {
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
adapter?.snapshot()?.getOrNull(position)?.id?.let { statusId ->
viewModel.saveReadingPosition(statusId)
}
}
@ -639,19 +641,19 @@ class TimelineFragment :
override fun onResume() {
super.onResume()
val a11yManager =
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val a11yManager = requireContext().getSystemService<AccessibilityManager>()
val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) {
val adapter = requireNotNull(this.adapter)
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}

View file

@ -20,7 +20,7 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@ -57,18 +57,22 @@ class TrendingTagsFragment :
private val binding by viewBinding(FragmentTrendingTagsBinding::bind)
private val adapter = TrendingTagsAdapter(::onViewTag)
private var adapter: TrendingTagsAdapter? = null
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val columnCount =
requireContext().resources.getInteger(R.integer.trending_column_count)
setupLayoutManager(columnCount)
adapter?.let {
setupLayoutManager(it, columnCount)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = TrendingTagsAdapter(::onViewTag)
this.adapter = adapter
setupSwipeRefreshLayout()
setupRecyclerView()
setupRecyclerView(adapter)
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
@ -87,13 +91,17 @@ class TrendingTagsFragment :
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collectLatest { trendingState ->
processViewState(trendingState)
processViewState(adapter, trendingState)
}
}
if (activity is ActionButtonActivity) {
(activity as ActionButtonActivity).actionButton?.visibility = View.GONE
}
(requireActivity() as? ActionButtonActivity)?.actionButton?.visibility = View.GONE
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
private fun setupSwipeRefreshLayout() {
@ -101,7 +109,7 @@ class TrendingTagsFragment :
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupLayoutManager(columnCount: Int) {
private fun setupLayoutManager(adapter: TrendingTagsAdapter, columnCount: Int) {
binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply {
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
@ -115,10 +123,10 @@ class TrendingTagsFragment :
}
}
private fun setupRecyclerView() {
private fun setupRecyclerView(adapter: TrendingTagsAdapter) {
val columnCount =
requireContext().resources.getInteger(R.integer.trending_column_count)
setupLayoutManager(columnCount)
setupLayoutManager(adapter, columnCount)
binding.recyclerView.setHasFixedSize(true)
@ -136,19 +144,22 @@ class TrendingTagsFragment :
)
}
private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) {
private fun processViewState(
adapter: TrendingTagsAdapter,
uiState: TrendingTagsViewModel.TrendingTagsUiState
) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) {
TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(adapter, uiState.trendingViewData)
TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError()
}
}
private fun applyLoadedState(viewData: List<TrendingViewData>) {
private fun applyLoadedState(adapter: TrendingTagsAdapter, viewData: List<TrendingViewData>) {
clearLoadingState()
adapter.submitList(viewData)
@ -216,13 +227,13 @@ class TrendingTagsFragment :
override fun onResume() {
super.onResume()
val a11yManager =
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val a11yManager = requireContext().getSystemService<AccessibilityManager>()
val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) {
val adapter = requireNotNull(this.adapter)
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
@ -233,7 +244,7 @@ class TrendingTagsFragment :
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}

View file

@ -18,12 +18,10 @@ package com.keylesspalace.tusky.components.viewthread
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.CheckResult
import androidx.core.view.MenuProvider
@ -65,7 +63,7 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class ViewThreadFragment :
SFragment(),
SFragment(R.layout.fragment_view_thread),
OnRefreshListener,
StatusActionListener,
MenuProvider {
@ -77,7 +75,7 @@ class ViewThreadFragment :
private val binding by viewBinding(FragmentViewThreadBinding::bind)
private lateinit var adapter: ThreadAdapter
private var adapter: ThreadAdapter? = null
private lateinit var thisThreadsStatusId: String
private var alwaysShowSensitiveMedia = false
@ -96,7 +94,9 @@ class ViewThreadFragment :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!!
}
private fun createAdapter(): ThreadAdapter {
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -116,20 +116,15 @@ class ViewThreadFragment :
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = ThreadAdapter(statusDisplayOptions, this)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_view_thread, container, false)
return ThreadAdapter(statusDisplayOptions, this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val adapter = createAdapter()
this.adapter = adapter
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
@ -256,6 +251,12 @@ class ViewThreadFragment :
viewModel.loadThread(thisThreadsStatusId)
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_view_thread, menu)
val actionReveal = menu.findItem(R.id.action_reveal)
@ -322,11 +323,12 @@ class ViewThreadFragment :
}
override fun onReply(position: Int) {
super.reply(adapter.currentList[position].status)
val viewData = adapter?.currentList?.getOrNull(position) ?: return
super.reply(viewData.status)
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.reblog(reblog, status)
}
@ -342,8 +344,8 @@ class ViewThreadFragment :
}
private fun onTranslate(position: Int) {
val status = adapter.currentList[position]
lifecycleScope.launch {
val status = adapter?.currentList?.getOrNull(position) ?: return
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status)
.onFailure {
Snackbar.make(
@ -356,22 +358,22 @@ class ViewThreadFragment :
}
override fun onUntranslate(position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.bookmark(bookmark, status)
}
override fun onMore(view: View, position: Int) {
val viewData = adapter.currentList[position]
val viewData = adapter?.currentList?.getOrNull(position) ?: return
super.more(
viewData.status,
view,
@ -381,7 +383,7 @@ class ViewThreadFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
super.viewMedia(
attachmentIndex,
list(status, alwaysShowSensitiveMedia),
@ -390,7 +392,7 @@ class ViewThreadFragment :
}
override fun onViewThread(position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
if (thisThreadsStatusId == status.id) {
// If already viewing this thread, don't reopen it.
return
@ -415,11 +417,13 @@ class ViewThreadFragment :
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.changeExpanded(expanded, adapter.currentList[position])
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.changeContentShowing(isShowing, adapter.currentList[position])
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onLoadMore(position: Int) {
@ -427,19 +431,20 @@ class ViewThreadFragment :
}
override fun onShowReblogs(position: Int) {
val statusId = adapter.currentList[position].id
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
val status = adapter?.currentList?.getOrNull(position) ?: return
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, status.id)
requireActivity().startActivityWithSlideInAnimation(intent)
}
override fun onShowFavs(position: Int) {
val statusId = adapter.currentList[position].id
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
val status = adapter?.currentList?.getOrNull(position) ?: return
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, status.id)
requireActivity().startActivityWithSlideInAnimation(intent)
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position])
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
override fun onViewTag(tag: String) {
@ -451,7 +456,7 @@ class ViewThreadFragment :
}
public override fun removeItem(position: Int) {
adapter.currentList.getOrNull(position)?.let { status ->
adapter?.currentList?.getOrNull(position)?.let { status ->
if (status.isDetailed) {
// the main status we are viewing is being removed, finish the activity
activity?.finish()
@ -462,12 +467,12 @@ class ViewThreadFragment :
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.voteInPoll(choices, status)
}
override fun onShowEdits(position: Int) {
val status = adapter.currentList[position]
val status = adapter?.currentList?.getOrNull(position) ?: return
val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId)
parentFragmentManager.commit {
@ -483,7 +488,8 @@ class ViewThreadFragment :
}
override fun clearWarningAction(position: Int) {
viewModel.clearWarning(adapter.currentList[position])
val status = adapter?.currentList?.getOrNull(position) ?: return
viewModel.clearWarning(status)
}
companion object {

View file

@ -30,6 +30,7 @@ import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
@ -73,14 +74,16 @@ import kotlinx.coroutines.launch
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
abstract class SFragment : Fragment() {
abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) {
protected abstract fun removeItem(position: Int)
protected abstract fun onReblog(reblog: Boolean, position: Int)
/** `null` if translation is not supported on this screen */
protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)?
private lateinit var bottomSheetActivity: BottomSheetActivity
private val bottomSheetActivity: BottomSheetActivity
get() = (requireActivity() as? BottomSheetActivity)
?: throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
@Inject
lateinit var mastodonApi: MastodonApi
@ -114,15 +117,6 @@ abstract class SFragment : Fragment() {
requireActivity().startActivityWithSlideInAnimation(intent)
}
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY)
@ -357,7 +351,7 @@ abstract class SFragment : Fragment() {
}
R.id.pin -> {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
timelineCases.pin(status.id, !status.pinned)
.onFailure { e: Throwable ->
val message = e.message
@ -389,9 +383,9 @@ abstract class SFragment : Fragment() {
showMuteAccountDialog(
this.requireActivity(),
accountUsername
) { notifications: Boolean?, duration: Int? ->
) { notifications: Boolean, duration: Int? ->
lifecycleScope.launch {
timelineCases.mute(accountId, notifications == true, duration)
timelineCases.mute(accountId, notifications, duration)
}
}
}
@ -445,11 +439,11 @@ abstract class SFragment : Fragment() {
AlertDialog.Builder(requireActivity())
.setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
val result = timelineCases.delete(id).exceptionOrNull()
if (result != null) {
Log.w("SFragment", "error deleting status", result)
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), R.string.error_generic, Toast.LENGTH_SHORT).show()
}
// XXX: Removes the item even if there was an error. This is probably not
// correct (see similar code in showConfirmEditDialog() which only
@ -464,13 +458,12 @@ abstract class SFragment : Fragment() {
}
private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
if (activity == null) {
return
}
AlertDialog.Builder(requireActivity())
val context = context ?: return
AlertDialog.Builder(context)
.setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
timelineCases.delete(id).fold(
{ deletedStatus ->
removeItem(position)
@ -491,7 +484,7 @@ abstract class SFragment : Fragment() {
poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt),
kind = ComposeActivity.ComposeKind.NEW
)
startActivity(startIntent(requireContext(), composeOptions))
startActivity(startIntent(context, composeOptions))
},
{ error: Throwable? ->
Log.w("SFragment", "error deleting status", error)
@ -506,7 +499,7 @@ abstract class SFragment : Fragment() {
}
private fun editStatus(id: String, status: Status) {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
mastodonApi.statusSource(id).fold(
{ source ->
val composeOptions = ComposeOptions(

View file

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.fragment
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.os.Bundle
@ -35,7 +34,6 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentViewImageBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.getParcelableCompat
@ -57,8 +55,8 @@ class ViewImageFragment : ViewMediaFragment() {
private val binding by viewBinding(FragmentViewImageBinding::bind)
private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View
private val photoActionsListener: PhotoActionsListener
get() = requireActivity() as PhotoActionsListener
private var transition: CompletableDeferred<Unit>? = null
private var shouldStartTransition = false
@ -67,11 +65,6 @@ class ViewImageFragment : ViewMediaFragment() {
@Volatile
private var startedTransition = false
override fun onAttach(context: Context) {
super.onAttach(context)
photoActionsListener = context as PhotoActionsListener
}
override fun setupMediaView(
url: String,
previewUrl: String?,
@ -91,7 +84,6 @@ class ViewImageFragment : ViewMediaFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar
this.transition = CompletableDeferred()
return inflater.inflate(R.layout.fragment_view_image, container, false)
}

View file

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.fragment
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Handler
@ -33,6 +32,8 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.OptIn
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@ -54,6 +55,7 @@ import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.getParcelableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import dagger.hilt.android.AndroidEntryPoint
@ -73,19 +75,23 @@ class ViewVideoFragment : ViewMediaFragment() {
private val binding by viewBinding(FragmentViewVideoBinding::bind)
private lateinit var videoActionsListener: VideoActionsListener
private lateinit var toolbar: View
private val videoActionsListener: VideoActionsListener
get() = requireActivity() as VideoActionsListener
private val handler = Handler(Looper.getMainLooper())
private val hideToolbar = Runnable {
// Hoist toolbar hiding to activity so it can track state across different fragments
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
mediaActivity.onPhotoTap()
}
private lateinit var mediaActivity: ViewMediaActivity
private lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false
private val mediaActivity: ViewMediaActivity
get() = requireActivity() as ViewMediaActivity
private val isAudio
get() = mediaAttachment.type == Attachment.Type.AUDIO
private lateinit var mediaAttachment: Attachment
private val mediaAttachment: Attachment by unsafeLazy {
arguments?.getParcelableCompat<Attachment>(ARG_ATTACHMENT)
?: throw IllegalArgumentException("attachment has to be set")
}
private var player: ExoPlayer? = null
@ -101,20 +107,12 @@ class ViewVideoFragment : ViewMediaFragment() {
/** Prevent the next play start from queueing a toolbar hide. */
private var suppressNextHideToolbar = false
override fun onAttach(context: Context) {
super.onAttach(context)
videoActionsListener = context as VideoActionsListener
}
@SuppressLint("PrivateResource", "MissingInflatedId")
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar
val rootView = inflater.inflate(R.layout.fragment_view_video, container, false)
// Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar
@ -133,11 +131,6 @@ class ViewVideoFragment : ViewMediaFragment() {
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val attachment = arguments?.getParcelableCompat<Attachment>(ARG_ATTACHMENT)
?: throw IllegalArgumentException("attachment has to be set")
val url = attachment.url
isAudio = attachment.type == Attachment.Type.AUDIO
/**
* Handle single taps, flings, and dragging
@ -211,7 +204,7 @@ class ViewVideoFragment : ViewMediaFragment() {
}
}
mediaPlayerListener = object : Player.Listener {
val mediaPlayerListener = object : Player.Listener {
@SuppressLint("ClickableViewAccessibility", "SyntheticAccessor")
@OptIn(UnstableApi::class)
override fun onPlaybackStateChanged(playbackState: Int) {
@ -273,25 +266,23 @@ class ViewVideoFragment : ViewMediaFragment() {
savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0
mediaAttachment = attachment
val attachment = mediaAttachment
finalizeViewSetup(attachment.url, attachment.previewUrl, attachment.description)
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
}
// Lifecycle callbacks
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
initializePlayer(mediaPlayerListener)
binding.videoView.onResume()
}
override fun onStart() {
super.onStart()
initializePlayer()
binding.videoView.onResume()
}
override fun onStop() {
super.onStop()
// This might be multi-window, so pause everything now.
binding.videoView.onPause()
releasePlayer()
handler.removeCallbacks(hideToolbar)
override fun onStop(owner: LifecycleOwner) {
// This might be multi-window, so pause everything now.
binding.videoView.onPause()
releasePlayer()
handler.removeCallbacks(hideToolbar)
}
})
}
override fun onSaveInstanceState(outState: Bundle) {
@ -299,7 +290,7 @@ class ViewVideoFragment : ViewMediaFragment() {
outState.putLong(SEEK_POSITION, savedSeekPosition)
}
private fun initializePlayer() {
private fun initializePlayer(mediaPlayerListener: Player.Listener) {
player = playerProvider.get().apply {
setAudioAttributes(
AudioAttributes.Builder()