-
Notifications
You must be signed in to change notification settings - Fork 9.2k
Add smooth tab reorder animation during drag #19598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,335 @@ | ||||||||
| // Copyright (c) Microsoft Corporation. | ||||||||
| // Licensed under the MIT license. | ||||||||
|
|
||||||||
| #include "pch.h" | ||||||||
| #include "TabReorderAnimator.h" | ||||||||
|
|
||||||||
| using namespace winrt::Windows::Foundation; | ||||||||
| using namespace winrt::Windows::UI::Xaml; | ||||||||
| using namespace winrt::Windows::UI::Xaml::Media; | ||||||||
| using namespace winrt::Windows::UI::Xaml::Media::Animation; | ||||||||
|
|
||||||||
| namespace MUX = winrt::Microsoft::UI::Xaml::Controls; | ||||||||
|
|
||||||||
| static constexpr int AnimationDurationMs = 200; | ||||||||
| static constexpr double DefaultTabWidthFallback = 200.0; | ||||||||
|
|
||||||||
| namespace winrt::TerminalApp::implementation | ||||||||
| { | ||||||||
| TabReorderAnimator::TabReorderAnimator(const MUX::TabView& tabView, bool animationsEnabled) : | ||||||||
| _tabView{ tabView }, | ||||||||
| _animationsEnabled{ animationsEnabled } | ||||||||
| { | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::SetAnimationsEnabled(bool enabled) | ||||||||
| { | ||||||||
| _animationsEnabled = enabled; | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::OnDragStarting(uint32_t draggedTabIndex) | ||||||||
| { | ||||||||
| _isDragging = true; | ||||||||
| _draggedTabIndex = static_cast<int>(draggedTabIndex); | ||||||||
| _currentGapIndex = _draggedTabIndex; | ||||||||
|
|
||||||||
| _EnsureTransformsSetup(); | ||||||||
| _DisableBuiltInTransitions(); | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::OnDragOver(const DragEventArgs& e) | ||||||||
| { | ||||||||
| if (!_isDragging && _draggedTabIndex < 0) | ||||||||
| { | ||||||||
| // Cross-window drag initialization | ||||||||
| _isDragging = true; | ||||||||
| _draggedTabIndex = -1; | ||||||||
| _currentGapIndex = -1; | ||||||||
| _EnsureTransformsSetup(); | ||||||||
| _DisableBuiltInTransitions(); | ||||||||
| } | ||||||||
|
|
||||||||
| const auto pos = e.GetPosition(_tabView); | ||||||||
| const auto newGapIndex = _CalculateGapIndex(pos.X); | ||||||||
|
|
||||||||
| if (newGapIndex != _currentGapIndex) | ||||||||
| { | ||||||||
| _AnimateTabsToMakeGap(newGapIndex); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::OnDragCompleted() | ||||||||
| { | ||||||||
| // Snap transforms back immediately (no animation) so we don't conflict | ||||||||
| // with TabView's built-in reorder animation | ||||||||
| _ResetAllTransforms(false); | ||||||||
| _RestoreBuiltInTransitions(); | ||||||||
|
|
||||||||
| _isDragging = false; | ||||||||
| _draggedTabIndex = -1; | ||||||||
| _currentGapIndex = -1; | ||||||||
| _transforms.clear(); | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::OnDragLeave() | ||||||||
| { | ||||||||
| _ResetAllTransforms(true); | ||||||||
| _RestoreBuiltInTransitions(); | ||||||||
|
|
||||||||
| _isDragging = false; | ||||||||
| _draggedTabIndex = -1; | ||||||||
| _currentGapIndex = -1; | ||||||||
| _transforms.clear(); | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_EnsureTransformsSetup() | ||||||||
| { | ||||||||
| _StopAllAnimations(); | ||||||||
| _transforms.clear(); | ||||||||
|
|
||||||||
| const auto tabCount = _tabView.TabItems().Size(); | ||||||||
|
|
||||||||
| for (uint32_t i = 0; i < tabCount; i++) | ||||||||
| { | ||||||||
| if (const auto item = _tabView.ContainerFromIndex(i).try_as<MUX::TabViewItem>()) | ||||||||
| { | ||||||||
| auto transform = item.RenderTransform().try_as<TranslateTransform>(); | ||||||||
| if (!transform) | ||||||||
| { | ||||||||
| transform = TranslateTransform{}; | ||||||||
| item.RenderTransform(transform); | ||||||||
| } | ||||||||
| transform.X(0.0); | ||||||||
| _transforms.push_back(transform); | ||||||||
| } | ||||||||
| else | ||||||||
| { | ||||||||
| _transforms.push_back(nullptr); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| int TabReorderAnimator::_CalculateGapIndex(double pointerX) const | ||||||||
| { | ||||||||
| const auto tabCount = static_cast<int>(_tabView.TabItems().Size()); | ||||||||
|
|
||||||||
| for (int i = 0; i < tabCount; i++) | ||||||||
| { | ||||||||
| if (i == _draggedTabIndex) | ||||||||
| { | ||||||||
| continue; | ||||||||
| } | ||||||||
|
|
||||||||
| if (const auto item = _tabView.ContainerFromIndex(i).try_as<MUX::TabViewItem>()) | ||||||||
| { | ||||||||
| const auto itemTransform = item.TransformToVisual(_tabView); | ||||||||
| const auto itemPos = itemTransform.TransformPoint({ 0, 0 }); | ||||||||
| const auto tabMidpoint = itemPos.X + (item.ActualWidth() / 2); | ||||||||
|
|
||||||||
| if (pointerX < tabMidpoint) | ||||||||
| { | ||||||||
| return i; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| return tabCount; | ||||||||
| } | ||||||||
|
|
||||||||
| double TabReorderAnimator::_GetTabWidth() const | ||||||||
| { | ||||||||
| const auto tabCount = _tabView.TabItems().Size(); | ||||||||
|
|
||||||||
| for (uint32_t i = 0; i < tabCount; i++) | ||||||||
| { | ||||||||
| if (static_cast<int>(i) != _draggedTabIndex) | ||||||||
| { | ||||||||
| if (const auto item = _tabView.ContainerFromIndex(i).try_as<MUX::TabViewItem>()) | ||||||||
| { | ||||||||
| return item.ActualWidth(); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| if (_draggedTabIndex >= 0 && _draggedTabIndex < static_cast<int>(tabCount)) | ||||||||
| { | ||||||||
| if (const auto item = _tabView.ContainerFromIndex(_draggedTabIndex).try_as<MUX::TabViewItem>()) | ||||||||
| { | ||||||||
| return item.ActualWidth(); | ||||||||
| } | ||||||||
| } | ||||||||
|
Comment on lines
+143
to
+160
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need both? Why not just the second block? |
||||||||
|
|
||||||||
| return DefaultTabWidthFallback; | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_AnimateTabsToMakeGap(int gapIndex) | ||||||||
| { | ||||||||
| _currentGapIndex = gapIndex; | ||||||||
| const auto tabWidth = _GetTabWidth(); | ||||||||
| const auto tabCount = static_cast<int>(_transforms.size()); | ||||||||
|
|
||||||||
| _StopAllAnimations(); | ||||||||
|
|
||||||||
| for (int i = 0; i < tabCount; i++) | ||||||||
| { | ||||||||
| if (i == _draggedTabIndex) | ||||||||
| { | ||||||||
| continue; | ||||||||
| } | ||||||||
|
|
||||||||
| auto& transform = _transforms[i]; | ||||||||
| if (!transform) | ||||||||
| { | ||||||||
| continue; | ||||||||
| } | ||||||||
|
|
||||||||
| double targetOffset = 0.0; | ||||||||
|
|
||||||||
| // Only animate for same-window drags. Cross-window drags don't shift tabs | ||||||||
| // because there's no source gap to fill, and shifting right would push | ||||||||
| // tabs off-screen. | ||||||||
| if (_draggedTabIndex >= 0) | ||||||||
| { | ||||||||
| if (_draggedTabIndex < gapIndex) | ||||||||
| { | ||||||||
| if (i > _draggedTabIndex && i < gapIndex) | ||||||||
| { | ||||||||
| targetOffset = -tabWidth; | ||||||||
| } | ||||||||
| } | ||||||||
| else if (_draggedTabIndex > gapIndex) | ||||||||
| { | ||||||||
| if (i >= gapIndex && i < _draggedTabIndex) | ||||||||
| { | ||||||||
| targetOffset = tabWidth; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| _AnimateTransformTo(transform, targetOffset); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_AnimateTransformTo(const TranslateTransform& transform, double targetX) | ||||||||
| { | ||||||||
| if (!transform) | ||||||||
| { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| if (!_animationsEnabled) | ||||||||
| { | ||||||||
| transform.X(targetX); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| if (std::abs(transform.X() - targetX) < 0.5) | ||||||||
| { | ||||||||
| transform.X(targetX); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| const auto duration = DurationHelper::FromTimeSpan( | ||||||||
| TimeSpan{ std::chrono::milliseconds(AnimationDurationMs) }); | ||||||||
|
Comment on lines
+232
to
+233
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
nit: no need to split into 2 lines |
||||||||
|
|
||||||||
| DoubleAnimation animation; | ||||||||
| animation.Duration(duration); | ||||||||
| animation.To(targetX); | ||||||||
| animation.EasingFunction(QuadraticEase{}); | ||||||||
| animation.EnableDependentAnimation(true); | ||||||||
|
|
||||||||
| Storyboard sb; | ||||||||
| sb.Duration(duration); | ||||||||
| sb.Children().Append(animation); | ||||||||
| sb.SetTarget(animation, transform); | ||||||||
| sb.SetTargetProperty(animation, L"X"); | ||||||||
|
|
||||||||
| _activeAnimations.push_back(sb); | ||||||||
| sb.Begin(); | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_StopAllAnimations() | ||||||||
| { | ||||||||
| for (auto& anim : _activeAnimations) | ||||||||
| { | ||||||||
| if (anim) | ||||||||
| { | ||||||||
| anim.Stop(); | ||||||||
| } | ||||||||
| } | ||||||||
| _activeAnimations.clear(); | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_ResetAllTransforms(bool animated) | ||||||||
| { | ||||||||
| _StopAllAnimations(); | ||||||||
|
|
||||||||
| for (auto& transform : _transforms) | ||||||||
| { | ||||||||
| if (transform) | ||||||||
| { | ||||||||
| if (animated && _animationsEnabled) | ||||||||
| { | ||||||||
| _AnimateTransformTo(transform, 0.0); | ||||||||
| } | ||||||||
| else | ||||||||
| { | ||||||||
| transform.X(0.0); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_DisableBuiltInTransitions() | ||||||||
| { | ||||||||
| try | ||||||||
| { | ||||||||
| const auto childCount = VisualTreeHelper::GetChildrenCount(_tabView); | ||||||||
| for (int32_t i = 0; i < childCount; i++) | ||||||||
| { | ||||||||
| if (const auto listView = VisualTreeHelper::GetChild(_tabView, i).try_as<Controls::ListView>()) | ||||||||
| { | ||||||||
| if (!_transitionsSaved) | ||||||||
| { | ||||||||
| _savedTransitions = listView.ItemContainerTransitions(); | ||||||||
| _transitionsSaved = true; | ||||||||
| } | ||||||||
| listView.ItemContainerTransitions(nullptr); | ||||||||
| break; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| catch (...) | ||||||||
| { | ||||||||
| // Do nothing on failure - visual tree structure may vary | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| void TabReorderAnimator::_RestoreBuiltInTransitions() | ||||||||
| { | ||||||||
| if (!_transitionsSaved) | ||||||||
| { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| try | ||||||||
| { | ||||||||
| const auto childCount = VisualTreeHelper::GetChildrenCount(_tabView); | ||||||||
| for (int32_t i = 0; i < childCount; i++) | ||||||||
| { | ||||||||
| if (const auto listView = VisualTreeHelper::GetChild(_tabView, i).try_as<Controls::ListView>()) | ||||||||
| { | ||||||||
| listView.ItemContainerTransitions(_savedTransitions); | ||||||||
| break; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| catch (...) | ||||||||
| { | ||||||||
| // Do nothing on failure - visual tree structure may vary | ||||||||
| } | ||||||||
|
|
||||||||
| _savedTransitions = nullptr; | ||||||||
| _transitionsSaved = false; | ||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT license. | ||
|
|
||
| #pragma once | ||
|
|
||
| namespace winrt::TerminalApp::implementation | ||
| { | ||
| class TabReorderAnimator | ||
| { | ||
| public: | ||
| TabReorderAnimator(const Microsoft::UI::Xaml::Controls::TabView& tabView, bool animationsEnabled); | ||
|
|
||
| void OnDragStarting(uint32_t draggedTabIndex); | ||
| void OnDragOver(const Windows::UI::Xaml::DragEventArgs& e); | ||
| void OnDragCompleted(); | ||
| void OnDragLeave(); | ||
|
|
||
| void SetAnimationsEnabled(bool enabled); | ||
|
|
||
| private: | ||
| void _AnimateTabsToMakeGap(int gapIndex); | ||
| void _ResetAllTransforms(bool animated); | ||
| int _CalculateGapIndex(double pointerX) const; | ||
| void _EnsureTransformsSetup(); | ||
| double _GetTabWidth() const; | ||
| void _AnimateTransformTo(const Windows::UI::Xaml::Media::TranslateTransform& transform, double targetX); | ||
| void _StopAllAnimations(); | ||
| void _DisableBuiltInTransitions(); | ||
| void _RestoreBuiltInTransitions(); | ||
|
|
||
| Microsoft::UI::Xaml::Controls::TabView _tabView{ nullptr }; | ||
| int _draggedTabIndex{ -1 }; | ||
| int _currentGapIndex{ -1 }; | ||
| std::vector<Windows::UI::Xaml::Media::TranslateTransform> _transforms; | ||
| std::vector<Windows::UI::Xaml::Media::Animation::Storyboard> _activeAnimations; | ||
| bool _animationsEnabled{ true }; | ||
| bool _isDragging{ false }; | ||
|
|
||
| Windows::UI::Xaml::Media::Animation::TransitionCollection _savedTransitions{ nullptr }; | ||
| bool _transitionsSaved{ false }; | ||
|
Comment on lines
+39
to
+40
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need |
||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: set the size of
_transformstotabCountsince it'll always be that.