From 116decbba98159d8552a956626efb1c09637862c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 24 Sep 2026 14:02:47 +0200 Subject: [PATCH 1/8] fix(media): navigation Signed-off-by: alperozturk96 # Conflicts: # app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt --- .../utils/extensions/ActivityExtensions.kt | 16 +++ .../ui/activity/FileDisplayActivity.kt | 107 +++++++++++++++--- .../android/ui/adapter/OCFileListDelegate.kt | 2 +- .../ui/fragment/OCFileListFragment.java | 13 ++- .../OCFileListFragmentInterface.java | 4 + .../ui/preview/PreviewImageActivity.kt | 14 ++- .../preview/model/DetailsFromPreviewState.kt | 20 ++++ app/src/main/res/anim/hold.xml | 11 ++ app/src/main/res/anim/slide_in_bottom.xml | 12 ++ 9 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt create mode 100644 app/src/main/res/anim/hold.xml create mode 100644 app/src/main/res/anim/slide_in_bottom.xml diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt index c02e4e9f95f7..52104d299b11 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt @@ -9,8 +9,11 @@ package com.nextcloud.utils.extensions import android.app.Activity import android.content.Intent +import android.graphics.Rect +import android.os.Bundle import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityOptionsCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -37,6 +40,19 @@ fun Activity.showShareIntent(text: String?) { startActivity(shareIntent) } +fun Activity.scaleUpAnimationFrom(boundsOnScreen: Rect): Bundle? { + val decorView = window.decorView + val decorLocation = IntArray(2).also { decorView.getLocationOnScreen(it) } + + return ActivityOptionsCompat.makeScaleUpAnimation( + decorView, + boundsOnScreen.left - decorLocation[0], + boundsOnScreen.top - decorLocation[1], + boundsOnScreen.width(), + boundsOnScreen.height() + ).toBundle() +} + fun ComponentActivity.observeWorker(onCollect: (WorkerState?) -> Unit) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 858b4700cb69..53012e41ba86 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -27,6 +27,7 @@ import android.content.ServiceConnection import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources +import android.graphics.Rect import android.net.Uri import android.os.Build import android.os.Bundle @@ -45,6 +46,7 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView +import androidx.core.app.ActivityOptionsCompat import androidx.core.util.Function import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible @@ -80,6 +82,7 @@ import com.nextcloud.model.OCUploadLocalPathData import com.nextcloud.model.WorkerState.OfflineOperationsCompleted import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias import com.nextcloud.utils.SnackbarUtil +import com.nextcloud.utils.extensions.boundsOnScreen import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.nextcloud.utils.extensions.isActive @@ -87,6 +90,7 @@ import com.nextcloud.utils.extensions.isDialogFragmentReady import com.nextcloud.utils.extensions.lastFragment import com.nextcloud.utils.extensions.navigateToAllFiles import com.nextcloud.utils.extensions.observeWorker +import com.nextcloud.utils.extensions.scaleUpAnimationFrom import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFolderPath import com.nextcloud.utils.view.FastScrollUtils @@ -157,6 +161,7 @@ import com.owncloud.android.ui.preview.PreviewImageFragment import com.owncloud.android.ui.preview.PreviewTextFileFragment import com.owncloud.android.ui.preview.PreviewTextFragment import com.owncloud.android.ui.preview.PreviewTextStringFragment +import com.owncloud.android.ui.preview.model.DetailsFromPreviewState import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment.Companion.newInstance import com.owncloud.android.utils.DataHolderUtil import com.owncloud.android.utils.ErrorMessageAdapter @@ -280,6 +285,8 @@ class FileDisplayActivity : */ private var fileIDForImmediatePreview: Long = -1 + private var detailsFromPreview: DetailsFromPreviewState? = null + private lateinit var folderRefreshScheduler: FolderRefreshScheduler fun setFileIDForImmediatePreview(fileIDForImmediatePreview: Long) { @@ -613,9 +620,13 @@ class FileDisplayActivity : when { ACTION_DETAILS.equals(action, ignoreCase = true) -> { + val fileBeforeDetails = getFile() val file = getFileFromIntent(intent) setFile(file) showDetails(file) + if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { + detailsFromPreview = DetailsFromPreviewState.ShowingDetails(intent, fileBeforeDetails) + } } Intent.ACTION_SEARCH == action -> handleSearchIntent(intent) @@ -752,6 +763,7 @@ class FileDisplayActivity : return } + detailsFromPreview = null prepareFragmentBeforeCommit(showSortListGroup) commitFragment(fragment) } @@ -1268,6 +1280,12 @@ class FileDisplayActivity : after() } + detailsFromPreview is DetailsFromPreviewState.ShowingDetails && leftFragment is FileDetailFragment -> { + before() + returnToPreviewFromDetails() + after() + } + // pop back if current fragment is AlbumItemsFragment isAlbumItemsFragment -> { before() @@ -1289,6 +1307,41 @@ class FileDisplayActivity : } } + private fun returnToPreviewFromDetails() { + val state = detailsFromPreview as? DetailsFromPreviewState.ShowingDetails ?: return + detailsFromPreview = null + + val detailsIntent = state.detailsIntent + val virtualFolderType = detailsIntent.getSerializableArgument( + PreviewImageActivity.EXTRA_VIRTUAL_TYPE, + VirtualFolderType::class.java + ) + val mediaState = detailsIntent.getSerializableArgument( + PreviewImageActivity.EXTRA_MEDIA_STATE, + MediaState::class.java + ) + val previewIntent = getFileFromIntent(detailsIntent)?.let { + imagePreviewIntent(it, virtualFolderType, mediaState) + } + + if (previewIntent == null) { + leaveDetails(state.fileBeforeDetails) + return + } + + // leaving the details right away would flash the list behind the preview while it fades in + detailsFromPreview = DetailsFromPreviewState.ReturnedToPreview(state.fileBeforeDetails) + val options = ActivityOptionsCompat.makeCustomAnimation(this, android.R.anim.fade_in, R.anim.hold) + startActivity(previewIntent, options.toBundle()) + } + + private fun leaveDetails(fileBeforeDetails: OCFile?) { + // onResume would otherwise pick the details file up from the intent again + intent?.removeExtra(EXTRA_FILE) + fileBeforeDetails?.let { setFile(it) } + popBack() + } + private fun handleOCFileListFragmentBackPress() { val fragment = leftFragment as OCFileListFragment @@ -2061,7 +2114,12 @@ class FileDisplayActivity : fun canPreviewInMediaPager(file: OCFile?): Boolean = PreviewImageFragment.canBePreviewed(file) || (file != null && MimeTypeUtil.isVideo(file)) - fun previewImageWithSearchContext(file: OCFile, searchFragment: Boolean, currentSearchType: SearchType?) { + fun previewImageWithSearchContext( + file: OCFile, + searchFragment: Boolean, + currentSearchType: SearchType?, + sourceView: View? + ) { val type = if (searchFragment) { when (currentSearchType) { SearchType.FAVORITE_SEARCH -> VirtualFolderType.FAVORITE @@ -2079,7 +2137,7 @@ class FileDisplayActivity : } val showPreview = file.isDown || MimeTypeUtil.isVideo(file) - startImagePreview(file, showPreview, type, mediaState) + startImagePreview(file, showPreview, type, mediaState, sourceView?.boundsOnScreen()) } fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) { @@ -2711,23 +2769,14 @@ class FileDisplayActivity : file: OCFile, showPreview: Boolean, type: VirtualFolderType? = null, - mediaState: MediaState? = null + mediaState: MediaState? = null, + sourceBounds: Rect? = null ) { - if (user.isEmpty) { - Log_OC.e(TAG, "cannot start image preview") - return - } - - val intent = Intent(this, PreviewImageActivity::class.java).apply { - putExtra(EXTRA_FILE, file) - putExtra(EXTRA_LIVE_PHOTO_FILE, file.livePhotoVideo) - putExtra(EXTRA_USER, user.get()) - type?.let { putExtra(PreviewImageActivity.EXTRA_VIRTUAL_TYPE, it) } - mediaState?.let { putExtra(PreviewImageActivity.EXTRA_MEDIA_STATE, it) } - } + val intent = imagePreviewIntent(file, type, mediaState) ?: return + intent.sourceBounds = sourceBounds if (showPreview) { - startActivity(intent) + startActivity(intent, sourceBounds?.let { scaleUpAnimationFrom(it) }) } else { val helper = FileOperationsHelper( this, @@ -2739,6 +2788,21 @@ class FileDisplayActivity : } } + private fun imagePreviewIntent(file: OCFile, type: VirtualFolderType?, mediaState: MediaState?): Intent? { + if (user.isEmpty) { + Log_OC.e(TAG, "cannot start image preview") + return null + } + + return Intent(this, PreviewImageActivity::class.java).apply { + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_LIVE_PHOTO_FILE, file.livePhotoVideo) + putExtra(EXTRA_USER, user.get()) + type?.let { putExtra(PreviewImageActivity.EXTRA_VIRTUAL_TYPE, it) } + mediaState?.let { putExtra(PreviewImageActivity.EXTRA_MEDIA_STATE, it) } + } + } + /** * Starts the preview of an audio [OCFile], synchronizing it first when it is not available yet. */ @@ -2966,10 +3030,11 @@ class FileDisplayActivity : file, true, virtualType, - bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java) + bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java), + event.intent.sourceBounds ) } else { - startImagePreview(file, true) + startImagePreview(file, true, sourceBounds = event.intent.sourceBounds) } } @@ -2987,6 +3052,11 @@ class FileDisplayActivity : registerReceivers() + (detailsFromPreview as? DetailsFromPreviewState.ReturnedToPreview)?.let { + detailsFromPreview = null + leaveDetails(it.fileBeforeDetails) + } + if (SettingsActivity.isBackPressed) { Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic") return @@ -3320,6 +3390,7 @@ class FileDisplayActivity : private const val SEARCH_VIEW_FOCUS_DELAY = 100L const val ACTION_DETAILS: String = "com.owncloud.android.ui.activity.action.DETAILS" + const val EXTRA_RETURN_TO_PREVIEW: String = "RETURN_TO_PREVIEW" @JvmField val REQUEST_CODE__SELECT_CONTENT_FROM_APPS: Int = REQUEST_CODE__LAST_SHARED + 1 diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index 095e258e3923..9532989b90d0 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -170,7 +170,7 @@ class OCFileListDelegate( file ) } else { - ocFileListFragmentInterface.onItemClicked(file) + ocFileListFragmentInterface.onItemClicked(file, imageView) AlbumItemsFragment.lastMediaItemPosition = galleryRowHolder.absoluteAdapterPosition } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index e2e517b34599..e6c773841bce 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1187,7 +1187,7 @@ private Integer checkFileBeforeOpen(OCFile file) { } } - private void fileOnItemClick(OCFile file) { + private void fileOnItemClick(OCFile file, @Nullable View sourceView) { Integer errorMessageId = checkFileBeforeOpen(file); final var recyclerView = getRecyclerView(); if (recyclerView != null && errorMessageId != null) { @@ -1196,7 +1196,7 @@ private void fileOnItemClick(OCFile file) { } if (mContainerActivity instanceof FileDisplayActivity fda && fda.canPreviewInMediaPager(file)) { - fda.previewImageWithSearchContext(file, searchFragment, currentSearchType); + fda.previewImageWithSearchContext(file, searchFragment, currentSearchType, sourceView); } else if (file.isDown() && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewFile(file, this::setFabVisible); } else { @@ -1257,8 +1257,13 @@ private void handlePendingDownloadFile(OCFile file) { } @Override - @OptIn(markerClass = UnstableApi.class) public void onItemClicked(OCFile file) { + onItemClicked(file, null); + } + + @Override + @OptIn(markerClass = UnstableApi.class) + public void onItemClicked(OCFile file, @Nullable View sourceView) { if (getCommonAdapter() != null && getCommonAdapter().isMultiSelect()) { toggleItemToCheckedList(file); } else { @@ -1276,7 +1281,7 @@ public void onItemClicked(OCFile file) { requireActivity().setResult(Activity.RESULT_OK, intent); requireActivity().finish(); } else if (!mOnlyFoldersClickable) { - fileOnItemClick(file); + fileOnItemClick(file, sourceView); } } } diff --git a/app/src/main/java/com/owncloud/android/ui/interfaces/OCFileListFragmentInterface.java b/app/src/main/java/com/owncloud/android/ui/interfaces/OCFileListFragmentInterface.java index 2d47d841299e..c1a620aaddbb 100644 --- a/app/src/main/java/com/owncloud/android/ui/interfaces/OCFileListFragmentInterface.java +++ b/app/src/main/java/com/owncloud/android/ui/interfaces/OCFileListFragmentInterface.java @@ -30,6 +30,10 @@ public interface OCFileListFragmentInterface { void onItemClicked(OCFile file); + default void onItemClicked(OCFile file, View sourceView) { + onItemClicked(file); + } + boolean onLongItemClicked(OCFile file); boolean isLoading(); diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 76b54dbdc0a5..574220521e7c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -16,6 +16,7 @@ import android.os.Build import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback +import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat @@ -490,13 +491,20 @@ class PreviewImageActivity : @SuppressFBWarnings("DLS") override fun showDetails(file: OCFile) { - val intent = Intent(this, FileDisplayActivity::class.java).apply { + val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) + val mediaState = intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) + + val detailsIntent = Intent(this, FileDisplayActivity::class.java).apply { setAction(FileDisplayActivity.ACTION_DETAILS) putExtra(EXTRA_FILE, file) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(FileDisplayActivity.EXTRA_RETURN_TO_PREVIEW, true) + virtualFolderType?.let { putExtra(EXTRA_VIRTUAL_TYPE, it) } + mediaState?.let { putExtra(EXTRA_MEDIA_STATE, it) } + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } - startActivity(intent) + val options = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.slide_in_bottom, R.anim.hold) + startActivity(detailsIntent, options.toBundle()) finish() } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt b/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt new file mode 100644 index 000000000000..fc1dc5bf8147 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.preview.model + +import android.content.Intent +import com.owncloud.android.datamodel.OCFile + +sealed interface DetailsFromPreviewState { + val fileBeforeDetails: OCFile? + + data class ShowingDetails(val detailsIntent: Intent, override val fileBeforeDetails: OCFile?) : + DetailsFromPreviewState + + data class ReturnedToPreview(override val fileBeforeDetails: OCFile?) : DetailsFromPreviewState +} diff --git a/app/src/main/res/anim/hold.xml b/app/src/main/res/anim/hold.xml new file mode 100644 index 000000000000..006bdf76455f --- /dev/null +++ b/app/src/main/res/anim/hold.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_in_bottom.xml new file mode 100644 index 000000000000..69043030cfdf --- /dev/null +++ b/app/src/main/res/anim/slide_in_bottom.xml @@ -0,0 +1,12 @@ + + + From b5359e24752d5b245bb2d8927ee52f303d451704 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 29 Sep 2026 16:30:25 +0200 Subject: [PATCH 2/8] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/utils/extensions/ViewExtensions.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt index d4d79fbd1c37..3d6ef567ce2e 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt @@ -11,6 +11,7 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context import android.graphics.Outline +import android.graphics.Rect import android.util.TypedValue import android.view.View import android.view.ViewOutlineProvider @@ -20,6 +21,11 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.google.android.material.behavior.HideViewOnScrollBehavior +fun View.boundsOnScreen(): Rect { + val location = IntArray(2).also { getLocationOnScreen(it) } + return Rect(location[0], location[1], location[0] + width, location[1] + height) +} + fun View?.setVisibleIf(condition: Boolean) { if (this == null) return visibility = if (condition) View.VISIBLE else View.GONE From 576af4953e8796db223c0be6666894372caa4219 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 30 Sep 2026 08:32:05 +0200 Subject: [PATCH 3/8] create navigation animator Signed-off-by: alperozturk96 --- .../ui/activity/FileDisplayActivity.kt | 6 ++--- .../navigation/animator/NavigationAnimator.kt | 27 +++++++++++++++++++ .../ui/preview/PreviewImageActivity.kt | 6 ++--- app/src/main/res/anim/slide_down.xml | 12 +++++++++ .../{slide_in_bottom.xml => slide_up.xml} | 0 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt create mode 100644 app/src/main/res/anim/slide_down.xml rename app/src/main/res/anim/{slide_in_bottom.xml => slide_up.xml} (100%) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 53012e41ba86..da9cb945a0da 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -46,7 +46,6 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView -import androidx.core.app.ActivityOptionsCompat import androidx.core.util.Function import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible @@ -156,6 +155,7 @@ import com.owncloud.android.ui.helpers.FileOperationsHelper import com.owncloud.android.ui.helpers.UriUploader import com.owncloud.android.ui.interfaces.TransactionInterface import com.owncloud.android.ui.navigation.NavigatorScreen +import com.owncloud.android.ui.navigation.animator.NavigationAnimator import com.owncloud.android.ui.preview.PreviewImageActivity import com.owncloud.android.ui.preview.PreviewImageFragment import com.owncloud.android.ui.preview.PreviewTextFileFragment @@ -1331,8 +1331,8 @@ class FileDisplayActivity : // leaving the details right away would flash the list behind the preview while it fades in detailsFromPreview = DetailsFromPreviewState.ReturnedToPreview(state.fileBeforeDetails) - val options = ActivityOptionsCompat.makeCustomAnimation(this, android.R.anim.fade_in, R.anim.hold) - startActivity(previewIntent, options.toBundle()) + val navigationAnimator = NavigationAnimator(this) + navigationAnimator.slideDown(previewIntent) } private fun leaveDetails(fileBeforeDetails: OCFile?) { diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt new file mode 100644 index 000000000000..9a32fab88e28 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.navigation.animator + +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityOptionsCompat +import com.owncloud.android.R + +class NavigationAnimator(private val activity: AppCompatActivity) { + + fun slideUp(intent: Intent) { + val options = ActivityOptionsCompat.makeCustomAnimation(activity, R.anim.slide_up, R.anim.hold) + activity.startActivity(intent, options.toBundle()) + } + + fun slideDown(intent: Intent) { + val options = ActivityOptionsCompat.makeCustomAnimation(activity, R.anim.slide_down, R.anim.hold) + activity.startActivity(intent, options.toBundle()) + } + +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 574220521e7c..f5535fc4ddb3 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -16,7 +16,6 @@ import android.os.Build import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback -import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat @@ -66,6 +65,7 @@ import com.owncloud.android.ui.dialog.SendShareDialog import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.ui.fragment.GalleryFragment import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState +import com.owncloud.android.ui.navigation.animator.NavigationAnimator import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.MimeTypeUtil import edu.umd.cs.findbugs.annotations.SuppressFBWarnings @@ -503,8 +503,8 @@ class PreviewImageActivity : addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } - val options = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.slide_in_bottom, R.anim.hold) - startActivity(detailsIntent, options.toBundle()) + val navigationAnimator = NavigationAnimator(this) + navigationAnimator.slideUp(detailsIntent) finish() } diff --git a/app/src/main/res/anim/slide_down.xml b/app/src/main/res/anim/slide_down.xml new file mode 100644 index 000000000000..860ca03c15da --- /dev/null +++ b/app/src/main/res/anim/slide_down.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_up.xml similarity index 100% rename from app/src/main/res/anim/slide_in_bottom.xml rename to app/src/main/res/anim/slide_up.xml From 04264398e0a33318e934d6f73d631f035a379e79 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 30 Sep 2026 08:51:11 +0200 Subject: [PATCH 4/8] use m3 slide down and up Signed-off-by: alperozturk96 --- .../ui/activity/FileDisplayActivity.kt | 58 +++---------------- .../navigation/animator/NavigationAnimator.kt | 16 +++-- .../ui/preview/PreviewImageActivity.kt | 7 --- .../preview/model/DetailsFromPreviewState.kt | 20 ------- app/src/main/res/anim/hold.xml | 2 +- app/src/main/res/anim/slide_down.xml | 8 +-- app/src/main/res/anim/slide_up.xml | 4 +- 7 files changed, 28 insertions(+), 87 deletions(-) delete mode 100644 app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index da9cb945a0da..3f8720f5ca82 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -161,7 +161,6 @@ import com.owncloud.android.ui.preview.PreviewImageFragment import com.owncloud.android.ui.preview.PreviewTextFileFragment import com.owncloud.android.ui.preview.PreviewTextFragment import com.owncloud.android.ui.preview.PreviewTextStringFragment -import com.owncloud.android.ui.preview.model.DetailsFromPreviewState import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment.Companion.newInstance import com.owncloud.android.utils.DataHolderUtil import com.owncloud.android.utils.ErrorMessageAdapter @@ -285,7 +284,7 @@ class FileDisplayActivity : */ private var fileIDForImmediatePreview: Long = -1 - private var detailsFromPreview: DetailsFromPreviewState? = null + private var isShowingDetailsFromPreview = false private lateinit var folderRefreshScheduler: FolderRefreshScheduler @@ -453,6 +452,10 @@ class FileDisplayActivity : createMinFragments(savedInstanceState) } + if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { + handleSpecialIntents(intent) + } + upgradeNotificationForInstantUpload() checkOutdatedServer() checkNotifications() @@ -620,13 +623,10 @@ class FileDisplayActivity : when { ACTION_DETAILS.equals(action, ignoreCase = true) -> { - val fileBeforeDetails = getFile() val file = getFileFromIntent(intent) setFile(file) showDetails(file) - if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { - detailsFromPreview = DetailsFromPreviewState.ShowingDetails(intent, fileBeforeDetails) - } + isShowingDetailsFromPreview = intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false) } Intent.ACTION_SEARCH == action -> handleSearchIntent(intent) @@ -763,7 +763,7 @@ class FileDisplayActivity : return } - detailsFromPreview = null + isShowingDetailsFromPreview = false prepareFragmentBeforeCommit(showSortListGroup) commitFragment(fragment) } @@ -1280,9 +1280,9 @@ class FileDisplayActivity : after() } - detailsFromPreview is DetailsFromPreviewState.ShowingDetails && leftFragment is FileDetailFragment -> { + isShowingDetailsFromPreview && leftFragment is FileDetailFragment -> { before() - returnToPreviewFromDetails() + NavigationAnimator(this).finishWithSlideDown() after() } @@ -1307,41 +1307,6 @@ class FileDisplayActivity : } } - private fun returnToPreviewFromDetails() { - val state = detailsFromPreview as? DetailsFromPreviewState.ShowingDetails ?: return - detailsFromPreview = null - - val detailsIntent = state.detailsIntent - val virtualFolderType = detailsIntent.getSerializableArgument( - PreviewImageActivity.EXTRA_VIRTUAL_TYPE, - VirtualFolderType::class.java - ) - val mediaState = detailsIntent.getSerializableArgument( - PreviewImageActivity.EXTRA_MEDIA_STATE, - MediaState::class.java - ) - val previewIntent = getFileFromIntent(detailsIntent)?.let { - imagePreviewIntent(it, virtualFolderType, mediaState) - } - - if (previewIntent == null) { - leaveDetails(state.fileBeforeDetails) - return - } - - // leaving the details right away would flash the list behind the preview while it fades in - detailsFromPreview = DetailsFromPreviewState.ReturnedToPreview(state.fileBeforeDetails) - val navigationAnimator = NavigationAnimator(this) - navigationAnimator.slideDown(previewIntent) - } - - private fun leaveDetails(fileBeforeDetails: OCFile?) { - // onResume would otherwise pick the details file up from the intent again - intent?.removeExtra(EXTRA_FILE) - fileBeforeDetails?.let { setFile(it) } - popBack() - } - private fun handleOCFileListFragmentBackPress() { val fragment = leftFragment as OCFileListFragment @@ -3052,11 +3017,6 @@ class FileDisplayActivity : registerReceivers() - (detailsFromPreview as? DetailsFromPreviewState.ReturnedToPreview)?.let { - detailsFromPreview = null - leaveDetails(it.fileBeforeDetails) - } - if (SettingsActivity.isBackPressed) { Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic") return diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt index 9a32fab88e28..398d1a8cacbd 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -7,7 +7,9 @@ package com.owncloud.android.ui.navigation.animator +import android.app.Activity import android.content.Intent +import android.os.Build import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat import com.owncloud.android.R @@ -19,9 +21,15 @@ class NavigationAnimator(private val activity: AppCompatActivity) { activity.startActivity(intent, options.toBundle()) } - fun slideDown(intent: Intent) { - val options = ActivityOptionsCompat.makeCustomAnimation(activity, R.anim.slide_down, R.anim.hold) - activity.startActivity(intent, options.toBundle()) - } + fun finishWithSlideDown() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + activity.overrideActivityTransition(Activity.OVERRIDE_TRANSITION_CLOSE, R.anim.hold, R.anim.slide_down) + activity.finish() + return + } + activity.finish() + @Suppress("DEPRECATION") + activity.overridePendingTransition(R.anim.hold, R.anim.slide_down) + } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index f5535fc4ddb3..2774d73d3ac7 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -491,21 +491,14 @@ class PreviewImageActivity : @SuppressFBWarnings("DLS") override fun showDetails(file: OCFile) { - val virtualFolderType = intent.getSerializableArgument(EXTRA_VIRTUAL_TYPE, VirtualFolderType::class.java) - val mediaState = intent.getSerializableArgument(EXTRA_MEDIA_STATE, MediaState::class.java) - val detailsIntent = Intent(this, FileDisplayActivity::class.java).apply { setAction(FileDisplayActivity.ACTION_DETAILS) putExtra(EXTRA_FILE, file) putExtra(FileDisplayActivity.EXTRA_RETURN_TO_PREVIEW, true) - virtualFolderType?.let { putExtra(EXTRA_VIRTUAL_TYPE, it) } - mediaState?.let { putExtra(EXTRA_MEDIA_STATE, it) } - addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } val navigationAnimator = NavigationAnimator(this) navigationAnimator.slideUp(detailsIntent) - finish() } override fun showDetails(file: OCFile, activeTab: Int) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt b/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt deleted file mode 100644 index fc1dc5bf8147..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/model/DetailsFromPreviewState.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.owncloud.android.ui.preview.model - -import android.content.Intent -import com.owncloud.android.datamodel.OCFile - -sealed interface DetailsFromPreviewState { - val fileBeforeDetails: OCFile? - - data class ShowingDetails(val detailsIntent: Intent, override val fileBeforeDetails: OCFile?) : - DetailsFromPreviewState - - data class ReturnedToPreview(override val fileBeforeDetails: OCFile?) : DetailsFromPreviewState -} diff --git a/app/src/main/res/anim/hold.xml b/app/src/main/res/anim/hold.xml index 006bdf76455f..abda1127a739 100644 --- a/app/src/main/res/anim/hold.xml +++ b/app/src/main/res/anim/hold.xml @@ -6,6 +6,6 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later --> diff --git a/app/src/main/res/anim/slide_down.xml b/app/src/main/res/anim/slide_down.xml index 860ca03c15da..0c7e012f8bb5 100644 --- a/app/src/main/res/anim/slide_down.xml +++ b/app/src/main/res/anim/slide_down.xml @@ -6,7 +6,7 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later --> + android:duration="250" + android:fromYDelta="0" + android:interpolator="@android:interpolator/fast_out_linear_in" + android:toYDelta="100%p" /> diff --git a/app/src/main/res/anim/slide_up.xml b/app/src/main/res/anim/slide_up.xml index 69043030cfdf..cb01a99880b7 100644 --- a/app/src/main/res/anim/slide_up.xml +++ b/app/src/main/res/anim/slide_up.xml @@ -6,7 +6,7 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later --> From a039a14bd47fc6ff3e29384ebdf6625460da887f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 30 Sep 2026 09:25:59 +0200 Subject: [PATCH 5/8] use scale up and down for opening and close image Signed-off-by: alperozturk96 --- .../utils/extensions/ActivityExtensions.kt | 16 ----- .../utils/extensions/ViewExtensions.kt | 6 -- .../ui/activity/FileDisplayActivity.kt | 19 +++--- .../android/ui/adapter/OCFileListDelegate.kt | 3 + .../navigation/animator/NavigationAnimator.kt | 26 ++++++++ .../animator/SharedElementTransition.kt | 64 +++++++++++++++++++ .../ui/preview/PreviewImageActivity.kt | 27 +++++++- 7 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt index 52104d299b11..c02e4e9f95f7 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ActivityExtensions.kt @@ -9,11 +9,8 @@ package com.nextcloud.utils.extensions import android.app.Activity import android.content.Intent -import android.graphics.Rect -import android.os.Bundle import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityOptionsCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -40,19 +37,6 @@ fun Activity.showShareIntent(text: String?) { startActivity(shareIntent) } -fun Activity.scaleUpAnimationFrom(boundsOnScreen: Rect): Bundle? { - val decorView = window.decorView - val decorLocation = IntArray(2).also { decorView.getLocationOnScreen(it) } - - return ActivityOptionsCompat.makeScaleUpAnimation( - decorView, - boundsOnScreen.left - decorLocation[0], - boundsOnScreen.top - decorLocation[1], - boundsOnScreen.width(), - boundsOnScreen.height() - ).toBundle() -} - fun ComponentActivity.observeWorker(onCollect: (WorkerState?) -> Unit) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt index 3d6ef567ce2e..d4d79fbd1c37 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ViewExtensions.kt @@ -11,7 +11,6 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context import android.graphics.Outline -import android.graphics.Rect import android.util.TypedValue import android.view.View import android.view.ViewOutlineProvider @@ -21,11 +20,6 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.google.android.material.behavior.HideViewOnScrollBehavior -fun View.boundsOnScreen(): Rect { - val location = IntArray(2).also { getLocationOnScreen(it) } - return Rect(location[0], location[1], location[0] + width, location[1] + height) -} - fun View?.setVisibleIf(condition: Boolean) { if (this == null) return visibility = if (condition) View.VISIBLE else View.GONE diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 3f8720f5ca82..75d1a61d6150 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -27,7 +27,6 @@ import android.content.ServiceConnection import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources -import android.graphics.Rect import android.net.Uri import android.os.Build import android.os.Bundle @@ -48,6 +47,7 @@ import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView import androidx.core.util.Function import androidx.core.view.MenuItemCompat +import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -81,7 +81,6 @@ import com.nextcloud.model.OCUploadLocalPathData import com.nextcloud.model.WorkerState.OfflineOperationsCompleted import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias import com.nextcloud.utils.SnackbarUtil -import com.nextcloud.utils.extensions.boundsOnScreen import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument import com.nextcloud.utils.extensions.isActive @@ -89,7 +88,6 @@ import com.nextcloud.utils.extensions.isDialogFragmentReady import com.nextcloud.utils.extensions.lastFragment import com.nextcloud.utils.extensions.navigateToAllFiles import com.nextcloud.utils.extensions.observeWorker -import com.nextcloud.utils.extensions.scaleUpAnimationFrom import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFolderPath import com.nextcloud.utils.view.FastScrollUtils @@ -2102,7 +2100,7 @@ class FileDisplayActivity : } val showPreview = file.isDown || MimeTypeUtil.isVideo(file) - startImagePreview(file, showPreview, type, mediaState, sourceView?.boundsOnScreen()) + startImagePreview(file, showPreview, type, mediaState, sourceView) } fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) { @@ -2735,13 +2733,12 @@ class FileDisplayActivity : showPreview: Boolean, type: VirtualFolderType? = null, mediaState: MediaState? = null, - sourceBounds: Rect? = null + sourceView: View? = null ) { val intent = imagePreviewIntent(file, type, mediaState) ?: return - intent.sourceBounds = sourceBounds if (showPreview) { - startActivity(intent, sourceBounds?.let { scaleUpAnimationFrom(it) }) + NavigationAnimator(this).scaleUp(intent, sourceView) } else { val helper = FileOperationsHelper( this, @@ -2996,13 +2993,17 @@ class FileDisplayActivity : true, virtualType, bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java), - event.intent.sourceBounds + galleryThumbnailOf(file) ) } else { - startImagePreview(file, true, sourceBounds = event.intent.sourceBounds) + startImagePreview(file, true, sourceView = galleryThumbnailOf(file)) } } + private fun galleryThumbnailOf(file: OCFile): View? = leftFragment?.view + ?.findViewWithTag(file.fileId) + ?.takeIf { it.isShown && ViewCompat.getTransitionName(it) == NavigationAnimator.sharedElementName(file) } + @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(event: TokenPushEvent?) { if (!preferences.isKeysReInitEnabled()) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index 9532989b90d0..4b68daf84031 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -11,6 +11,7 @@ import android.content.Context import android.view.View import android.widget.ImageView import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat import com.elyeproj.loaderviewlibrary.LoaderImageView import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User @@ -39,6 +40,7 @@ import com.owncloud.android.ui.activity.FolderPickerActivity import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface +import com.owncloud.android.ui.navigation.animator.NavigationAnimator import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils @@ -120,6 +122,7 @@ class OCFileListDelegate( GalleryImageGenerationJob.cancelPreviousJob(imageView) imageView.tag = file.fileId + ViewCompat.setTransitionName(imageView, NavigationAnimator.sharedElementName(file)) val cached = file.takeUnless { it.isUpdateThumbnailNeeded }?.getBigThumbnail() if (cached != null) { diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt index 398d1a8cacbd..b55e722eb8f7 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -10,9 +10,12 @@ package com.owncloud.android.ui.navigation.animator import android.app.Activity import android.content.Intent import android.os.Build +import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile class NavigationAnimator(private val activity: AppCompatActivity) { @@ -32,4 +35,27 @@ class NavigationAnimator(private val activity: AppCompatActivity) { @Suppress("DEPRECATION") activity.overridePendingTransition(R.anim.hold, R.anim.slide_down) } + + fun scaleUp(intent: Intent, sourceView: View?) { + val sharedElementName = sourceView?.let { ViewCompat.getTransitionName(it) } + if (sourceView == null || sharedElementName == null) { + activity.startActivity(intent) + return + } + + intent.putExtra(EXTRA_HAS_SHARED_ELEMENT, true) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, sourceView, sharedElementName) + activity.startActivity(intent, options.toBundle()) + } + + fun finishWithScaleDown() { + activity.supportFinishAfterTransition() + } + + companion object { + const val EXTRA_HAS_SHARED_ELEMENT = "HAS_SHARED_ELEMENT" + private const val SHARED_ELEMENT_NAME_PREFIX = "file_" + + fun sharedElementName(file: OCFile): String = SHARED_ELEMENT_NAME_PREFIX + file.fileId + } } diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt new file mode 100644 index 000000000000..ec2ae5456998 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.navigation.animator + +import android.view.View +import android.view.ViewTreeObserver +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.SharedElementCallback + +class SharedElementTransition(private val activity: AppCompatActivity, private val sharedViewProvider: () -> View?) { + private val decorView: View + get() = activity.window.decorView + + private val startPostponedTransition: Runnable = Runnable { + decorView.viewTreeObserver.removeOnPreDrawListener(startWhenSharedViewReady) + decorView.removeCallbacks(startPostponedTransition) + activity.supportStartPostponedEnterTransition() + } + + private val startWhenSharedViewReady: ViewTreeObserver.OnPreDrawListener = ViewTreeObserver.OnPreDrawListener { + if (sharedViewProvider() != null) { + startPostponedTransition.run() + } + true + } + + fun register() { + activity.setEnterSharedElementCallback(object : SharedElementCallback() { + override fun onMapSharedElements(names: MutableList, sharedElements: MutableMap) { + val name = names.firstOrNull() ?: return + val sharedView = sharedViewProvider() + if (sharedView == null) { + names.clear() + sharedElements.clear() + return + } + sharedElements[name] = sharedView + } + + override fun onSharedElementStart( + names: MutableList, + sharedElements: MutableList, + snapshots: MutableList + ) { + sharedElements.forEach { it.background = null } + } + }) + } + + fun postponeUntilSharedViewReady() { + activity.supportPostponeEnterTransition() + decorView.viewTreeObserver.addOnPreDrawListener(startWhenSharedViewReady) + decorView.postDelayed(startPostponedTransition, MAX_POSTPONE_MS) + } + + companion object { + private const val MAX_POSTPONE_MS = 500L + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 2774d73d3ac7..191402506bc6 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -15,6 +15,7 @@ import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.MenuItem +import android.view.View import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable @@ -66,6 +67,7 @@ import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.ui.fragment.GalleryFragment import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.ui.navigation.animator.NavigationAnimator +import com.owncloud.android.ui.navigation.animator.SharedElementTransition import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.MimeTypeUtil import edu.umd.cs.findbugs.annotations.SuppressFBWarnings @@ -140,6 +142,7 @@ class PreviewImageActivity : setupDrawer(menuItemId) val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + setupSharedElementTransition(savedInstanceState, chosenFile) supportActionBar?.let { updateActionBarTitleAndHomeButton(chosenFile) @@ -304,11 +307,33 @@ class PreviewImageActivity : override fun handleOnBackPressed() { sendRefreshSearchEventBroadcast() isEnabled = false - onBackPressedDispatcher.onBackPressed() + NavigationAnimator(this@PreviewImageActivity).finishWithScaleDown() } }) } + private fun setupSharedElementTransition(savedInstanceState: Bundle?, openedFile: OCFile?) { + val sharedElementTransition = SharedElementTransition(this) { openedImageView() } + sharedElementTransition.register() + + val isLaunchedWithSharedElement = intent.getBooleanExtra(NavigationAnimator.EXTRA_HAS_SHARED_ELEMENT, false) + if (savedInstanceState == null && isLaunchedWithSharedElement && + PreviewImageFragment.canBePreviewed(openedFile) + ) { + sharedElementTransition.postponeUntilSharedViewReady() + } + } + + private fun openedImageView(): View? { + val openedFileId = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java)?.fileId + val shownFileId = viewPager?.currentItem?.let { previewMediaPagerAdapter?.getFileAt(it) }?.fileId + if (openedFileId == null || shownFileId != openedFileId) { + return null + } + + return viewPager?.findViewWithTag(openedFileId)?.takeIf { it.isShown && it.isLaidOut } + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId != android.R.id.home) { return super.onOptionsItemSelected(item) From 560bca92715168676dfebf67e332333875fbeda3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 30 Sep 2026 09:39:51 +0200 Subject: [PATCH 6/8] slide up image from center Signed-off-by: alperozturk96 --- .../ui/activity/FileDisplayActivity.kt | 7 +++ .../navigation/animator/NavigationAnimator.kt | 54 ++++++++++++++++--- .../animator/SharedElementTransition.kt | 14 ++++- .../ui/preview/PreviewImageActivity.kt | 11 +++- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 75d1a61d6150..698045bc7f6f 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -313,6 +313,10 @@ class FileDisplayActivity : initUI() initTaskRetainerFragment() + if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { + NavigationAnimator(this).prepareSlideUpEnter(savedInstanceState) { detailsHeaderImage() } + } + // Restoring after UI has been inflated. if (savedInstanceState != null) { showSortListGroup(savedInstanceState.getBoolean(KEY_IS_SORT_GROUP_VISIBLE)) @@ -3000,6 +3004,9 @@ class FileDisplayActivity : } } + private fun detailsHeaderImage(): View? = + previewImageView?.takeIf { it.isShown && it.isLaidOut && it.drawable != null } + private fun galleryThumbnailOf(file: OCFile): View? = leftFragment?.view ?.findViewWithTag(file.fileId) ?.takeIf { it.isShown && ViewCompat.getTransitionName(it) == NavigationAnimator.sharedElementName(file) } diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt index b55e722eb8f7..060cfd55073b 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -10,7 +10,11 @@ package com.owncloud.android.ui.navigation.animator import android.app.Activity import android.content.Intent import android.os.Build +import android.os.Bundle +import android.transition.Slide +import android.view.Gravity import android.view.View +import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat @@ -19,12 +23,40 @@ import com.owncloud.android.datamodel.OCFile class NavigationAnimator(private val activity: AppCompatActivity) { - fun slideUp(intent: Intent) { + private val isLaunchedWithSharedElement: Boolean + get() = activity.intent.getBooleanExtra(EXTRA_HAS_SHARED_ELEMENT, false) + + fun slideUp(intent: Intent, sharedView: View? = null) { + if (startWithSharedElement(intent, sharedView)) { + return + } + val options = ActivityOptionsCompat.makeCustomAnimation(activity, R.anim.slide_up, R.anim.hold) activity.startActivity(intent, options.toBundle()) } + fun prepareSlideUpEnter(savedInstanceState: Bundle?, sharedViewProvider: () -> View?) { + if (!isLaunchedWithSharedElement) { + return + } + + activity.window.enterTransition = bottomSlide() + activity.window.returnTransition = bottomSlide() + + val sharedElementTransition = + SharedElementTransition(activity, ImageView.ScaleType.FIT_CENTER, sharedViewProvider) + sharedElementTransition.register() + if (savedInstanceState == null) { + sharedElementTransition.postponeUntilSharedViewReady() + } + } + fun finishWithSlideDown() { + if (isLaunchedWithSharedElement) { + activity.supportFinishAfterTransition() + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { activity.overrideActivityTransition(Activity.OVERRIDE_TRANSITION_CLOSE, R.anim.hold, R.anim.slide_down) activity.finish() @@ -37,19 +69,27 @@ class NavigationAnimator(private val activity: AppCompatActivity) { } fun scaleUp(intent: Intent, sourceView: View?) { - val sharedElementName = sourceView?.let { ViewCompat.getTransitionName(it) } - if (sourceView == null || sharedElementName == null) { + if (!startWithSharedElement(intent, sourceView)) { activity.startActivity(intent) - return } + } + + fun finishWithScaleDown() { + activity.supportFinishAfterTransition() + } + + private fun startWithSharedElement(intent: Intent, sharedView: View?): Boolean { + val sharedElementName = sharedView?.let { ViewCompat.getTransitionName(it) } ?: return false intent.putExtra(EXTRA_HAS_SHARED_ELEMENT, true) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, sourceView, sharedElementName) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, sharedView, sharedElementName) activity.startActivity(intent, options.toBundle()) + return true } - fun finishWithScaleDown() { - activity.supportFinishAfterTransition() + private fun bottomSlide() = Slide(Gravity.BOTTOM).apply { + excludeTarget(android.R.id.statusBarBackground, true) + excludeTarget(android.R.id.navigationBarBackground, true) } companion object { diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt index ec2ae5456998..8268a40f17a9 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt @@ -9,10 +9,15 @@ package com.owncloud.android.ui.navigation.animator import android.view.View import android.view.ViewTreeObserver +import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.SharedElementCallback -class SharedElementTransition(private val activity: AppCompatActivity, private val sharedViewProvider: () -> View?) { +class SharedElementTransition( + private val activity: AppCompatActivity, + private val startScaleType: ImageView.ScaleType? = null, + private val sharedViewProvider: () -> View? +) { private val decorView: View get() = activity.window.decorView @@ -47,7 +52,12 @@ class SharedElementTransition(private val activity: AppCompatActivity, private v sharedElements: MutableList, snapshots: MutableList ) { - sharedElements.forEach { it.background = null } + sharedElements.forEach { + it.background = null + if (it is ImageView && startScaleType != null) { + it.scaleType = startScaleType + } + } } }) } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 191402506bc6..8de14c151080 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -331,9 +331,12 @@ class PreviewImageActivity : return null } - return viewPager?.findViewWithTag(openedFileId)?.takeIf { it.isShown && it.isLaidOut } + return shownImageView(openedFileId) } + private fun shownImageView(fileId: Long): View? = + viewPager?.findViewWithTag(fileId)?.takeIf { it.isShown && it.isLaidOut } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId != android.R.id.home) { return super.onOptionsItemSelected(item) @@ -522,8 +525,12 @@ class PreviewImageActivity : putExtra(FileDisplayActivity.EXTRA_RETURN_TO_PREVIEW, true) } + val imageView = shownImageView(file.fileId)?.also { + ViewCompat.setTransitionName(it, NavigationAnimator.sharedElementName(file)) + } + val navigationAnimator = NavigationAnimator(this) - navigationAnimator.slideUp(detailsIntent) + navigationAnimator.slideUp(detailsIntent, imageView) } override fun showDetails(file: OCFile, activeTab: Int) { From 78a4525bc6a4ff849a9b68d812fbdbef907558e4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 30 Sep 2026 10:54:18 +0200 Subject: [PATCH 7/8] fix transitions Signed-off-by: alperozturk96 --- .../navigation/animator/NavigationAnimator.kt | 17 +++++++++++++++-- app/src/main/res/values/styles.xml | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt index 060cfd55073b..e9d1fd77ab64 100644 --- a/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -17,6 +17,7 @@ import android.view.View import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat +import androidx.core.app.SharedElementCallback import androidx.core.view.ViewCompat import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -43,16 +44,21 @@ class NavigationAnimator(private val activity: AppCompatActivity) { activity.window.enterTransition = bottomSlide() activity.window.returnTransition = bottomSlide() - val sharedElementTransition = - SharedElementTransition(activity, ImageView.ScaleType.FIT_CENTER, sharedViewProvider) + val sharedElementTransition = SharedElementTransition( + activity, + startScaleType = ImageView.ScaleType.FIT_CENTER, + sharedViewProvider = sharedViewProvider + ) sharedElementTransition.register() if (savedInstanceState == null) { sharedElementTransition.postponeUntilSharedViewReady() } } + fun finishWithSlideDown() { if (isLaunchedWithSharedElement) { + activity.setEnterSharedElementCallback(withoutSharedElements()) activity.supportFinishAfterTransition() return } @@ -87,6 +93,13 @@ class NavigationAnimator(private val activity: AppCompatActivity) { return true } + private fun withoutSharedElements() = object : SharedElementCallback() { + override fun onMapSharedElements(names: MutableList, sharedElements: MutableMap) { + names.clear() + sharedElements.clear() + } + } + private fun bottomSlide() = Slide(Gravity.BOTTOM).apply { excludeTarget(android.R.id.statusBarBackground, true) excludeTarget(android.R.id.navigationBarBackground, true) diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 3b48bcb6524a..b01edd987c4e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -217,6 +217,8 @@