Skip to content

Repository files navigation

Metaphor

💠Android Material's motion system animations.

Google

License Medium Profile Android Weekly

Kotlin Weekly



Who's using Metaphor?

👉 Check out who's using Metaphor

Include in your project

Maven Central

Gradle

Add the dependency below to your module's build.gradle file:

dependencies {
    implementation("io.github.androidpoet:metaphor:2.0.0")
}

Metaphor provides support for all four motion patterns defined in the Material spec, plus elevation scale and hold.

  1. Container transform
  2. Shared axis
  3. Fade through
  4. Fade

How it works

Metaphor separates what animates from where it animates.

  • A Motion is one Material pattern as plain data: Motion.Fade(), Motion.SharedAxis.x(), Motion.ContainerTransform() and so on. Each carries only its own parameters and defaults to the duration the Material spec recommends.
  • A MotionSpec is the set of motions for one screen: enter, exit, popEnter, popExit and an optional sharedElement. It holds no reference to a Fragment or Activity, so it can be declared once and reused anywhere.
  • applyMotion(...) is the only thing a target needs to call. It exists for Fragment, Activity and PopupWindow. Views use morphInto, animateVisibility and animateChanges.
object Motions {
  val listOrigin = motionSpec {
    exit = Motion.ElevationScale.shrink()
    popEnter = Motion.ElevationScale.grow()
  }
  val detail = motionSpec {
    sharedElement = Motion.ContainerTransform()
  }
}

Container transform

How to use in Fragments

The origin screen holds a list. The destination screen grows out of the tapped item and shrinks back into it on return.

// Origin fragment (the list)
class ArtistListFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      exit = Motion.ElevationScale.shrink()   // list fades back while the detail grows
      popEnter = Motion.ElevationScale.grow() // list comes forward again on return
    }
  }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    // Wait for the list to lay out so the return transform can find the item view.
    postponeEnterUntilDrawn()
  }

  private fun openDetail(itemView: View, item: Artist) {
    val extras = FragmentNavigatorExtras(itemView to item.id)
    findNavController().navigate(ArtistListFragmentDirections.toDetail(item), extras)
  }
}

// Destination fragment (the detail)
class ArtistDetailFragment : Fragment() {

  private val args: ArtistDetailFragmentArgs by navArgs()

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    applyMotion(
      spec = motionSpec {
        sharedElement = Motion.ContainerTransform(
          duration = 300,
          scrimColor = Color.TRANSPARENT,
          containerColor = Color.WHITE,
          path = MotionPath.Arc,
        )
      },
      sharedElement = SharedElement.Destination(view, args.item.id),
    )
  }
}

Motion.ContainerTransform takes scrimColor, containerColor, path (MotionPath.Arc or MotionPath.Linear), fadeMode (In, Out, Cross, Through) and duration.

How to use in Views

Morph one view into a sibling. The first view is hidden and the target is shown when the transition starts. Both views must share the same parent.

viewBinding.fabDetail.setOnClickListener {
  viewBinding.fabDetail.morphInto(viewBinding.controls, Motion.ContainerTransform(duration = 300))
}

viewBinding.controls.setOnClickListener {
  viewBinding.controls.morphInto(viewBinding.fabDetail)
}

How to use in Activities

// Origin activity
class ArtistListActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(binding.root)
    applyMotion(SharedElement.Origin) {
      sharedElement = Motion.ContainerTransform()
      exit = Motion.Hold()
    }
  }

  private fun openDetail(card: View) {
    card.transitionName = "card"
    val options = ActivityOptions.makeSceneTransitionAnimation(this, card, "card")
    startActivity(Intent(this, ArtistDetailActivity::class.java), options.toBundle())
  }
}

// Destination activity
class ArtistDetailActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(binding.root)
    applyMotion(SharedElement.Destination(binding.root, "card")) {
      sharedElement = Motion.ContainerTransform()
    }
  }
}

Shared axis

How to use in Fragments

Both screens set the same axis. forward = true plays when moving deeper, forward = false plays when popping back.

// Origin fragment
class SettingsFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      exit = Motion.SharedAxis.x(forward = true)
      popEnter = Motion.SharedAxis.x(forward = false)
    }
  }
}

// Destination fragment
class AccountFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      enter = Motion.SharedAxis.x(forward = true)
      popExit = Motion.SharedAxis.x(forward = false)
    }
  }
}

Motion.SharedAxis.y() and Motion.SharedAxis.z() work the same way, or build one directly:

Motion.SharedAxis(axis = Motion.SharedAxis.Axis.Z, forward = true, duration = 400)

How to use in Views

viewBinding.sharedX.setOnClickListener {
  viewBinding.img.toggleVisibility(Motion.SharedAxis.x(forward = true, duration = 1000))
}

viewBinding.sharedY.setOnClickListener {
  viewBinding.img.toggleVisibility(Motion.SharedAxis.y(forward = true))
}

viewBinding.sharedZ.setOnClickListener {
  viewBinding.img.toggleVisibility(Motion.SharedAxis.z(forward = true))
}

How to use in Activities

// Origin activity, in onCreate
applyMotion {
  exit = Motion.SharedAxis.x(forward = true)
  popEnter = Motion.SharedAxis.x(forward = false)
}

// Destination activity, in onCreate
applyMotion {
  enter = Motion.SharedAxis.x(forward = true)
  popExit = Motion.SharedAxis.x(forward = false)
}

Fade through

How to use in Fragments

Fade through suits screens that are not related, such as bottom navigation tabs.

// Origin fragment
class HomeFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      enter = Motion.FadeThrough()
      exit = Motion.FadeThrough()
    }
  }
}

// Destination fragment
class DashboardFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      enter = Motion.FadeThrough(duration = 300)
      exit = Motion.FadeThrough(duration = 300)
    }
  }
}

How to use in Views

viewBinding.materialFadeThrough.setOnClickListener {
  viewBinding.img2.toggleVisibility(Motion.FadeThrough(duration = 1000))
}

Swap two views in one go:

viewBinding.container.animateChanges(Motion.FadeThrough()) {
  viewBinding.summary.isVisible = false
  viewBinding.detail.isVisible = true
}

How to use in Activities

// In onCreate of both activities
applyMotion {
  enter = Motion.FadeThrough()
  exit = Motion.FadeThrough()
}

Fade

How to use in Fragments

// Origin fragment
class NotificationsFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      exit = Motion.Fade()
      popEnter = Motion.Fade()
    }
  }
}

// Destination fragment
class NotificationDetailFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    applyMotion {
      enter = Motion.Fade(duration = 150)
      popExit = Motion.Fade(duration = 75)
    }
  }
}

How to use in Views

viewBinding.materialFade.setOnClickListener {
  viewBinding.img3.toggleVisibility(Motion.Fade(duration = 1000))
}

// or set the state explicitly
viewBinding.banner.animateVisibility(visible = false, motion = Motion.Fade())

Elevation scale

Elevation scale is the usual partner of a container transform: the origin screen scales down and fades while the detail grows over it, then scales back up on return.

// Origin fragment, in onCreate
applyMotion {
  exit = Motion.ElevationScale.shrink()
  popEnter = Motion.ElevationScale.grow()
}

Hold

Hold keeps the origin screen frozen in place for the length of the transition. Use it on the screen an Activity container transform starts from so it does not fade under the growing container.

applyMotion(SharedElement.Origin) {
  sharedElement = Motion.ContainerTransform()
  exit = Motion.Hold(duration = 300)
}

PopupWindow

val popup = PopupWindow(contentView, WRAP_CONTENT, WRAP_CONTENT, true)
popup.applyMotion {
  enter = Motion.Fade()
  exit = Motion.Fade()
}
popup.showAsDropDown(anchor)

Declare once, apply anywhere

A MotionSpec has no reference to a Fragment or Activity, so app-wide motion lives in one place and each screen applies what it needs.

object Motions {
  val tab = motionSpec {
    enter = Motion.FadeThrough()
    exit = Motion.FadeThrough()
  }

  val listOrigin = motionSpec {
    exit = Motion.ElevationScale.shrink()
    popEnter = Motion.ElevationScale.grow()
  }

  fun detail(duration: Long = 300) = motionSpec {
    sharedElement = Motion.ContainerTransform(duration = duration)
  }
}

// Home tab
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  applyMotion(Motions.tab)
}

// List screen
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  applyMotion(Motions.listOrigin)
}

// Detail screen
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  super.onViewCreated(view, savedInstanceState)
  applyMotion(Motions.detail(), SharedElement.Destination(view, args.id))
}

Specs are plain data classes, so copy works for small variations:

val slowDetail = Motions.detail().copy(allowEnterOverlap = true)

Use from Java

MotionSpec spec = new MotionSpec(
    Motion.SharedAxis.x(true, 300L),   // enter
    Motion.SharedAxis.x(true, 300L),   // exit
    Motion.SharedAxis.x(false, 300L),  // popEnter
    Motion.SharedAxis.x(false, 300L),  // popExit
    null,                              // sharedElement
    false,                             // allowEnterOverlap
    false                              // allowReturnOverlap
);
FragmentMotionKt.applyMotion(fragment, spec, null);

Supported motions

Motion.Fade(duration = 150)
Motion.FadeThrough(duration = 300)
Motion.SharedAxis(axis = Axis.X, forward = true, duration = 300)   // also SharedAxis.x() / .y() / .z()
Motion.ElevationScale(growing = true, duration = 300)              // also ElevationScale.grow() / .shrink()
Motion.ContainerTransform(scrimColor, containerColor, path, fadeMode, duration = 300)
Motion.Hold(duration = 300)

Migrating from 1.x

The 1.x builders (MetaphorFragment, MetaphorActivity, MetaphorView, MetaphorWindow) and the MetaphorAnimation enum still compile and delegate to the new API, but are deprecated and will be removed in 3.0. hold() is now postponeEnterUntilDrawn(). The Factory and lazy metaphorFragment<T>() helpers were removed; declare a MotionSpec in an object and apply it instead. BottomNavigationView.show() / hide() moved to the com.androidpoet.metaphor.widgets package.

1.x 2.0
MetaphorFragment.Builder(this).setEnterAnimation(FadeThrough).build().animate() applyMotion { enter = Motion.FadeThrough() }
.setExitAnimation(ContainerTransform).setView(view).setTransitionName(name) applyMotion(spec, SharedElement.Destination(view, name))
MetaphorView.Builder(a).setEndView(b).setMetaphorAnimation(ContainerTransform) a.morphInto(b)
MetaphorView.Builder(v).setEndView(v).setMetaphorAnimation(Fade) v.toggleVisibility(Motion.Fade())
SharedAxisXBackward Motion.SharedAxis.x(forward = false)
ElevationScaleGrow / ElevationScale Motion.ElevationScale.grow() / .shrink()

images credit: https://picsum.photos/ (Unsplash photos)

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for more cool projects! 🤩

Buy Me A Coffee

License

Copyright 2022 AndroidPoet (Ranbir Singh)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

💠Android Material's motion system animations.

Topics

Resources

Stars

146 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages