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..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 @@ -47,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 @@ -152,6 +153,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 @@ -280,6 +282,8 @@ class FileDisplayActivity : */ private var fileIDForImmediatePreview: Long = -1 + private var isShowingDetailsFromPreview = false + private lateinit var folderRefreshScheduler: FolderRefreshScheduler fun setFileIDForImmediatePreview(fileIDForImmediatePreview: Long) { @@ -309,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)) @@ -446,6 +454,10 @@ class FileDisplayActivity : createMinFragments(savedInstanceState) } + if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { + handleSpecialIntents(intent) + } + upgradeNotificationForInstantUpload() checkOutdatedServer() checkNotifications() @@ -616,6 +628,7 @@ class FileDisplayActivity : val file = getFileFromIntent(intent) setFile(file) showDetails(file) + isShowingDetailsFromPreview = intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false) } Intent.ACTION_SEARCH == action -> handleSearchIntent(intent) @@ -752,6 +765,7 @@ class FileDisplayActivity : return } + isShowingDetailsFromPreview = false prepareFragmentBeforeCommit(showSortListGroup) commitFragment(fragment) } @@ -1268,6 +1282,12 @@ class FileDisplayActivity : after() } + isShowingDetailsFromPreview && leftFragment is FileDetailFragment -> { + before() + NavigationAnimator(this).finishWithSlideDown() + after() + } + // pop back if current fragment is AlbumItemsFragment isAlbumItemsFragment -> { before() @@ -2061,7 +2081,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 +2104,7 @@ class FileDisplayActivity : } val showPreview = file.isDown || MimeTypeUtil.isVideo(file) - startImagePreview(file, showPreview, type, mediaState) + startImagePreview(file, showPreview, type, mediaState, sourceView) } fun previewFile(file: OCFile, setFabVisible: CompletionCallback?) { @@ -2711,23 +2736,13 @@ class FileDisplayActivity : file: OCFile, showPreview: Boolean, type: VirtualFolderType? = null, - mediaState: MediaState? = null + mediaState: MediaState? = null, + sourceView: View? = 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 if (showPreview) { - startActivity(intent) + NavigationAnimator(this).scaleUp(intent, sourceView) } else { val helper = FileOperationsHelper( this, @@ -2739,6 +2754,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,13 +2996,21 @@ class FileDisplayActivity : file, true, virtualType, - bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java) + bundle.getSerializableArgument(PreviewImageActivity.EXTRA_MEDIA_STATE, MediaState::class.java), + galleryThumbnailOf(file) ) } else { - startImagePreview(file, true) + startImagePreview(file, true, sourceView = galleryThumbnailOf(file)) } } + 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) } + @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(event: TokenPushEvent?) { if (!preferences.isKeysReInitEnabled()) { @@ -3320,6 +3358,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..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) { @@ -170,7 +173,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/navigation/animator/NavigationAnimator.kt b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt new file mode 100644 index 000000000000..89e9435e02a1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/NavigationAnimator.kt @@ -0,0 +1,113 @@ +/* + * 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.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.app.SharedElementCallback +import androidx.core.view.ViewCompat +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile + +class NavigationAnimator(private val activity: AppCompatActivity) { + + 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, + startScaleType = ImageView.ScaleType.FIT_CENTER, + sharedViewProvider = sharedViewProvider + ) + sharedElementTransition.register() + if (savedInstanceState == null) { + sharedElementTransition.postponeUntilSharedViewReady() + } + } + + fun finishWithSlideDown() { + if (isLaunchedWithSharedElement) { + activity.setEnterSharedElementCallback(withoutSharedElements()) + 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() + return + } + + activity.finish() + @Suppress("DEPRECATION") + activity.overridePendingTransition(R.anim.hold, R.anim.slide_down) + } + + fun scaleUp(intent: Intent, sourceView: View?) { + if (!startWithSharedElement(intent, sourceView)) { + activity.startActivity(intent) + } + } + + 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, sharedView, sharedElementName) + activity.startActivity(intent, options.toBundle()) + 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) + } + + 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..8268a40f17a9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/navigation/animator/SharedElementTransition.kt @@ -0,0 +1,74 @@ +/* + * 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 android.widget.ImageView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.SharedElementCallback + +class SharedElementTransition( + private val activity: AppCompatActivity, + private val startScaleType: ImageView.ScaleType? = null, + 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 + if (it is ImageView && startScaleType != null) { + it.scaleType = startScaleType + } + } + } + }) + } + + 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 76b54dbdc0a5..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 @@ -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 @@ -65,6 +66,8 @@ 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.navigation.animator.SharedElementTransition import com.owncloud.android.ui.preview.model.PreviewImageActivityState import com.owncloud.android.utils.MimeTypeUtil import edu.umd.cs.findbugs.annotations.SuppressFBWarnings @@ -139,6 +142,7 @@ class PreviewImageActivity : setupDrawer(menuItemId) val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + setupSharedElementTransition(savedInstanceState, chosenFile) supportActionBar?.let { updateActionBarTitleAndHomeButton(chosenFile) @@ -303,11 +307,36 @@ 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 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) @@ -490,14 +519,18 @@ class PreviewImageActivity : @SuppressFBWarnings("DLS") override fun showDetails(file: OCFile) { - val intent = Intent(this, FileDisplayActivity::class.java).apply { + 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) } - startActivity(intent) - finish() + val imageView = shownImageView(file.fileId)?.also { + ViewCompat.setTransitionName(it, NavigationAnimator.sharedElementName(file)) + } + + val navigationAnimator = NavigationAnimator(this) + navigationAnimator.slideUp(detailsIntent, imageView) } override fun showDetails(file: OCFile, activeTab: Int) { diff --git a/app/src/main/res/anim/hold.xml b/app/src/main/res/anim/hold.xml new file mode 100644 index 000000000000..abda1127a739 --- /dev/null +++ b/app/src/main/res/anim/hold.xml @@ -0,0 +1,11 @@ + + + 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..0c7e012f8bb5 --- /dev/null +++ b/app/src/main/res/anim/slide_down.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/anim/slide_up.xml b/app/src/main/res/anim/slide_up.xml new file mode 100644 index 000000000000..cb01a99880b7 --- /dev/null +++ b/app/src/main/res/anim/slide_up.xml @@ -0,0 +1,12 @@ + + + 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 @@