How Compose Separates Font Resolution from UI Components
From FontFamily to android.graphics.Typeface
How Compose Separates Font Resolution from UI Components
From FontFamily to android.graphics.Typeface
Introduction
In the traditional Android View system, font management was closely tied to the TextView implementation. Whether a developer explicitly provided a Typeface object or defined a fontFamily in XML, the TextView (or its internal utilities) was ultimately responsible for obtaining a concrete Typeface instance to perform rendering.
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="안녕하세요"
android:fontFamily="@font/nanum_square_bold" />
If we look at the internal source code of TextView.java, we can see this imperative process in action. The method setTypefaceFromAttrs is responsible for making the decision on which font to use based on the provided attributes.
TextView::setTypefaceFromAttrs
// Simplified snippet from TextView.java
private void setTypefaceFromAttrs(@Nullable Typeface typeface, @Nullable String familyName,
@XMLTypefaceAttr int typefaceIndex, @Typeface.Style int style, int weight) {
if (typeface == null && familyName != null) {
// 1. TextView directly requests the System Font Map for a Typeface
// by its family name (e.g., "sans-serif").
final Typeface normalTypeface = Typeface.create(familyName, Typeface.NORMAL);
resolveStyleAndSetTypeface(normalTypeface, style, weight);
} else if (typeface != null) {
// 2. If a Typeface object is already provided, it uses it directly.
resolveStyleAndSetTypeface(typeface, style, weight);
}
// ...
}
After picking a candidate, the TextView goes a step further to handle specific weights and styles:
Typeface::resolveStyleAndSetTypeface
private void resolveStyleAndSetTypeface(@NonNull Typeface typeface, @Typeface.Style int style,
@IntRange(from = FontStyle.FONT_WEIGHT_UNSPECIFIED, to = FontStyle.FONT_WEIGHT_MAX)
int weight) {
if (weight >= 0) {
weight = Math.min(FontStyle.FONT_WEIGHT_MAX, weight);
final boolean italic = (style & Typeface.ITALIC) != 0;
setTypeface(Typeface.create(typeface, weight, italic));
} else {
setTypeface(typeface, style);
}
}
Jetpack Compose, however, moves font resolution out of the UI component itself and into a dedicated resolution system. In this declarative world, the UI toolkit no longer resolves fonts directly. Instead, it describes font requirements through a set of abstractions that are processed by a dedicated font resolution pipeline.
To understand how text is ultimately rendered in Compose, we need to follow the journey from a declarative FontFamily request to the native android.graphics.Typeface used by the Android rendering engine.
Overview: The New Architecture of Font Resolution
In the traditional Android View system, font resolution was largely handled within TextView. The widget itself was responsible for interpreting font-related attributes and obtaining the appropriate Typeface for rendering.
Jetpack Compose introduces a different architecture. Instead of embedding font resolution logic inside UI components, Compose treats font resolution as an independent process. A font request flows through several dedicated layers, each responsible for a specific task — from describing the requested font, to resolving it, to producing the final platform-specific Typeface.
Descriptor → Resolution → Platform Mapping → Typeface
Understanding these layers is key to understanding how Compose achieves a more flexible and scalable font system.
1. The Descriptors: FontFamily and Font
Everything starts with Descriptors. In Compose, we don’t handle raw font files; we deal with logical abstractions. This layer consists of two primary entities that work in tandem:
Font is a descriptor for a single font resource. It defines the metadata required during font resolution, such as weight, style, and loading strategy.
/**
* The interface of the font resource.
*
* @see ResourceFont
*/
@Immutable
interface Font {
/**
* The weight of the font. The system uses this to match a font to a font request that is given
* in a [androidx.compose.ui.text.SpanStyle].
*/
val weight: FontWeight
/**
* The style of the font, normal or italic. The system uses this to match a font to a font
* request that is given in a [androidx.compose.ui.text.SpanStyle].
*/
val style: FontStyle
/** Loading strategy for this font. */
val loadingStrategy: FontLoadingStrategy
get() = FontLoadingStrategy.Blocking
}
FontFamily is a collection of related Font instances and serves as the primary descriptor passed into the font resolution system. It represents the font requirements requested by the UI rather than the final typeface used for rendering.
/**
* The primary typography interface for Compose applications.
*
* @see FontListFontFamily
* @see GenericFontFamily
* @see FontFamily.Resolver
*/
@Immutable
sealed class FontFamily(canLoadSynchronously: Boolean) {
@Suppress("CanBePrimaryConstructorProperty") // for deprecation
@get:Deprecated(
message = "Unused property that has no meaning. Do not use.",
level = DeprecationLevel.ERROR,
)
val canLoadSynchronously = canLoadSynchronously
}
// A typical representation of the Descriptor layer
val MyCustomFontFamily = FontFamily(
Font(resId = R.font.my_font_regular, weight = FontWeight.Normal),
Font(resId = R.font.my_font_bold, weight = FontWeight.Bold)
)
Both Font and FontFamily are immutable descriptors. Their responsibility is not to resolve or load fonts themselves, but to describe font requirements that will later be processed by FontFamily.Resolver.
2. The Engine: FontFamily.Resolver (The Heart of Separation)
The most significant architectural shift is the introduction of the **FontFamily.Resolver**. While each TextView previously resolved fonts through its own font-handling logic, Compose delegates this responsibility to FontFamily.Resolver.
- Decoupling: The widget no longer needs to know how to fetch a font. It simply requests a typeface from the
Resolverbased on a given descriptor. - Centralized Strategy: All logic for font matching, fallback mechanisms, and caching policies is concentrated within this single engine.
sealed interface Resolver {
suspend fun preload(fontFamily: FontFamily)
fun resolve(
fontFamily: FontFamily? = null,
fontWeight: FontWeight = FontWeight.Normal,
fontStyle: FontStyle = FontStyle.Normal,
fontSynthesis: FontSynthesis = FontSynthesis.All,
): State<Any>
}
2.1 Where Does FontFamily.Resolver Come From?
At this point, an important question arises: if FontFamily.Resolver is responsible for font resolution, who creates it and how does a Text composable gain access to it?
Unlike the View system, where each TextView participates directly in font resolution, Compose provides a resolver through the composition itself. Text components do not create or own a resolver. Instead, they obtain the current resolver from LocalFontFamilyResolver.
@Composable
fun BasicText(/* ... */) {
// ...
val fontFamilyResolver = LocalFontFamilyResolver.current
This design is a direct consequence of Compose’s separation of concerns. A Text composable is responsible only for describing its font requirements through properties such as fontFamily, fontWeight, and fontStyle. The actual resolution process is delegated to the resolver provided by the surrounding composition.
The resolver is later used during text measurement and layout. During paragraph construction, AndroidParagraphIntrinsics receives a FontFamily.Resolver instance and uses it to resolve the requested typeface.
ParagraphIntrinsics.android.kt
internal class AndroidParagraphIntrinsics(
// ...
val fontFamilyResolver: FontFamily.Resolver,
// ...,
) : ParagraphIntrinsics {
// ...
init {
val resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface =
{ fontFamily, fontWeight, fontStyle, fontSynthesis ->
val result =
fontFamilyResolver.resolve(fontFamily, fontWeight, fontStyle, fontSynthesis)
// ...
}
}
In other words, a Text composable never resolves fonts directly. It simply describes what it wants, while FontFamily.Resolver orchestrates the process of resolving those requirements into a platform-specific Typeface.
2.2 What Happens Inside resolve()
Obtaining a resolver is only the beginning. The real work starts when Compose calls FontFamily.Resolver.resolve().
At a high level, the resolver performs three major tasks:
- Building a TypefaceRequest
- Cache Lookup
- Adapter Delegation
1 - Building a TypefaceRequest
Rather than passing individual font attributes throughout the pipeline, Compose consolidates them into a TypefaceRequest. This object becomes the canonical representation of a font lookup operation.
The request also acts as the cache key used by TypefaceRequestCache, ensuring that identical font lookups can be efficiently reused.
override fun resolve(
fontFamily: FontFamily?,
fontWeight: FontWeight,
fontStyle: FontStyle,
fontSynthesis: FontSynthesis,
): State<Any> {
return resolve(
TypefaceRequest(
platformResolveInterceptor.interceptFontFamily(fontFamily),
platformResolveInterceptor.interceptFontWeight(fontWeight),
platformResolveInterceptor.interceptFontStyle(fontStyle),
platformResolveInterceptor.interceptFontSynthesis(fontSynthesis),
platformFontLoader.cacheKey,
)
)
}
2 - Cache Lookup
Before invoking any adapter implementations, the resolver first checks whether the same TypefaceRequest has already been resolved.
This lookup happens through TypefaceRequestCache.runCached(). If a cached and cacheable result exists, it can be returned immediately without invoking any font adapter. This means that repeated requests for the same FontFamily, weight, style, and synthesis configuration can avoid going through the full resolution path again.
private fun resolve(typefaceRequest: TypefaceRequest): State<Any> {
val result =
typefaceRequestCache.runCached(typefaceRequest) { /* ... */ }
// ...
}
The important detail is that the adapter chain is passed as the fallback block to runCached(). In other words, the resolver does not call the adapters first. It asks the cache first, and only when the cache cannot provide a valid result does it execute the actual resolution logic.
fun runCached(
typefaceRequest: TypefaceRequest,
resolveTypeface: ((TypefaceResult) -> Unit) -> TypefaceResult,
): State<Any> {
synchronized(lock) {
resultCache[typefaceRequest]?.let {
if (it.cacheable) {
return it
} else {
resultCache.remove(typefaceRequest)
}
}
}
val currentTypefaceResult =
try {
resolveTypeface { finalResult ->
synchronized(lock) {
if (finalResult.cacheable) {
resultCache.put(typefaceRequest, finalResult)
} else {
resultCache.remove(typefaceRequest)
}
}
}
} catch (cause: Exception) {
throw IllegalStateException("Could not load font", cause)
}
synchronized(lock) {
if (resultCache[typefaceRequest] == null && currentTypefaceResult.cacheable) {
resultCache.put(typefaceRequest, currentTypefaceResult)
}
}
return currentTypefaceResult
}
This cache layer is one of the reasons font resolution can remain efficient even though Compose models fonts through several abstraction layers. The abstractions are not resolved from scratch every time. Once a TypefaceRequest produces a cacheable result, subsequent identical requests can reuse it.
In practice, this means most text rendering operations never need to resolve the same font twice. As a result, the cost of font resolution is typically paid only once per unique request.
3 — Adapter Delegation
When a cache miss occurs, the resolver proceeds to the actual resolution phase.
Rather than interacting with Android APIs directly, it delegates the work to a chain of FontFamilyTypefaceAdapter implementations.
fontListFontFamilyTypefaceAdapter.resolve(
typefaceRequest,
platformFontLoader,
onAsyncCompletion,
createDefaultTypeface,
)
?: platformFamilyTypefaceAdapter.resolve(
typefaceRequest,
platformFontLoader,
onAsyncCompletion,
createDefaultTypeface,
)
Notice that the resolver itself contains no Android-specific font resolution logic. Its responsibility is to coordinate the process, while the actual platform-dependent work is delegated to adapter implementations.
Different adapters are responsible for different categories of font families. FontListFontFamilyTypefaceAdapter resolves font-list based families, while PlatformFontFamilyTypefaceAdapter bridges Compose font requests to platform-provided font families and native typefaces.
Among them, PlatformFontFamilyTypefaceAdapter plays a particularly important role on Android because it is the component that ultimately translates Compose font abstractions into native Typeface operations.
3. The Bridge: PlatformFontFamilyTypefaceAdapter
When the resolver delegates font resolution to adapters, not all font families follow the same path. Some font families do not require custom font loading at all because they already correspond to platform-provided typefaces.
This is where PlatformFontFamilyTypefaceAdapter comes into play.
Unlike FontListFontFamilyTypefaceAdapter, which works with collections of Font descriptors, PlatformFontFamilyTypefaceAdapter handles font families that can be resolved directly by the underlying platform. Examples include:
FontFamily.DefaultFontFamily.SansSerifFontFamily.SerifFontFamily.MonospaceLoadedFontFamily
The implementation reveals this distinction clearly:
when (typefaceRequest.fontFamily) {
null,
is DefaultFontFamily ->
platformTypefaceResolver.createDefault(
typefaceRequest.fontWeight,
typefaceRequest.fontStyle,
)
is GenericFontFamily ->
platformTypefaceResolver.createNamed(
typefaceRequest.fontFamily,
typefaceRequest.fontWeight,
typefaceRequest.fontStyle,
)
is LoadedFontFamily ->
(typefaceRequest.fontFamily.typeface as AndroidTypeface)
.getNativeTypeface(
typefaceRequest.fontWeight,
typefaceRequest.fontStyle,
typefaceRequest.fontSynthesis,
)
else -> return null
}
Notice that no font matching, resource loading, or fallback chain construction occurs here. Instead, the adapter simply translates Compose abstractions into calls that the Android font system already understands.
For example, a request for FontFamily.SansSerif ultimately becomes a request for Android's native sans-serif typeface. In this sense, PlatformFontFamilyTypefaceAdapter acts as the bridge between Compose's font abstractions and the platform's existing typeface infrastructure.
However, not all font families can be resolved so easily. When developers define a custom FontFamily composed of multiple Font resources, Compose must perform considerably more work to determine the correct typeface. That responsibility belongs to FontListFontFamilyTypefaceAdapter.
4. Resolving Font-Based Families: FontListFontFamilyTypefaceAdapter
Custom font families represent the most sophisticated part of Compose’s font resolution system.
Consider the following example:
val AppFontFamily = FontFamily(
Font(R.font.roboto_regular, weight = FontWeight.Normal),
Font(R.font.roboto_bold, weight = FontWeight.Bold),
Font(R.font.roboto_italic, style = FontStyle.Italic)
)
Unlike platform-provided families such as SansSerif, Compose cannot immediately obtain a native Typeface from the operating system. Instead, it must determine which Font within the family best satisfies the requested weight and style.
This responsibility belongs to FontListFontFamilyTypefaceAdapter.
Given a request such as:
fontFamilyResolver.resolve(
fontFamily = AppFontFamily,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal,
fontSynthesis = FontSynthesis.All
)
the adapter performs several steps:
- Inspect the available
Fontentries in the family. - Find the closest match for the requested weight and style.
- Load the selected font resource.
- Apply fallback and synthesis rules when necessary.
- Produce the final platform-specific
Typeface.
This process is significantly more complex than the platform adapter because the adapter must reason about multiple candidate fonts rather than simply mapping a request to a known platform typeface.
The distinction between the two adapters reflects the overall architecture of Compose’s font system:
PlatformFontFamilyTypefaceAdapterhandles platform-provided font families.FontListFontFamilyTypefaceAdapterhandles developer-defined font families.FontFamily.Resolvercoordinates both without needing to know the platform-specific details of either implementation.
This separation of concerns allows Compose to support both simple platform fonts and sophisticated custom font families through the same high-level FontFamily API.
5. Why Does resolve() Return State?
At first glance, the signature of FontFamily.Resolver.resolve() looks somewhat surprising.
fun resolve(
fontFamily: FontFamily?,
fontWeight: FontWeight,
fontStyle: FontStyle,
fontSynthesis: FontSynthesis,
): State<Any>
Given that the purpose of the resolver is to obtain a typeface, one might expect this method to return a Typeface directly. Instead, Compose returns a State<Any>.
The reason becomes clear when we consider asynchronous font loading.
Async Fonts
Not all fonts are available immediately when text measurement begins. Some fonts may be loaded asynchronously through FontLoadingStrategy.Async.
In these situations, the final typeface may not be available at the moment resolve() is called. Returning a simple Typeface would make it difficult for the text system to react when the font eventually becomes available.
By returning a state-backed result, Compose can continue to observe changes to the resolved typeface over time.
TypefaceResult
Internally, the resolver produces instances of TypefaceResult.
When the requested typeface can be resolved immediately, Compose returns a TypefaceResult.Immutable.
if (result is TypefaceResult.Immutable) {
return result.value as Typeface
}
In this case, the typeface is final and no further updates are expected.
However, asynchronous font requests may produce results that continue to evolve as loading progresses. This allows the font system to expose a single API regardless of whether the font is available immediately or must be loaded later.
Dirty Tracking
This design becomes visible inside [AndroidParagraphIntrinsics](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/ParagraphIntrinsics.android.kt;l=114?q=AndroidParagraphIntrinsics&ss=androidx%2Fplatform%2Fframeworks%2Fsupport).
val result =
fontFamilyResolver.resolve(
fontFamily,
fontWeight,
fontStyle,
fontSynthesis
)
if (result !is TypefaceResult.Immutable) {
val newHead =
TypefaceDirtyTrackerLinkedList(
result,
resolvedTypefaces
)
resolvedTypefaces = newHead
}
Rather than immediately rebuilding the text layout, Compose registers non-immutable results for tracking.
When an asynchronously loaded font becomes available, the tracked state changes. The text layout system can then detect that the previously resolved typeface is no longer current and rebuild the paragraph using the updated font.
Why This Matters
This is one of the reasons Compose’s font system can support asynchronous font loading without exposing additional complexity to UI components.
A Text composable does not need to know whether a font is already available, still loading, or retrieved from cache. It simply describes its font requirements and delegates the rest of the process to the font resolution system.
From the perspective of the UI layer, font resolution remains a declarative operation. Internally, however, Compose maintains a state-aware pipeline capable of reacting to typeface updates as they occur.
6. Key Takeaway
The most important architectural shift in Compose’s font system is not the introduction of a new font API, but the separation of font resolution from UI components.
In the traditional View system, TextView was responsible for both describing text and resolving the typeface used to render it. Font selection, fallback handling, and typeface creation were all closely tied to the widget itself.
Compose takes a fundamentally different approach.
A Text composable does not resolve fonts. Instead, it describes its requirements through abstractions such as FontFamily, FontWeight, and FontStyle. These descriptors are then processed by a dedicated font resolution pipeline centered around FontFamily.Resolver.
Along the way, Compose:
- Converts font requirements into a
TypefaceRequest - Reuses previous results through
TypefaceRequestCache - Delegates platform-specific work to adapter implementations
- Supports asynchronous font loading through state-backed results
- Ultimately produces a native
android.graphics.Typeface
Conceptually, the entire flow can be summarized as follows:
Text Composable
↓
FontFamily
↓
FontFamily.Resolver
↓
TypefaceRequest
↓
TypefaceRequestCache
↓
FontFamilyTypefaceAdapter
├─ FontListFontFamilyTypefaceAdapter
└─ PlatformFontFamilyTypefaceAdapter
↓
android.graphics.Typeface
This architecture allows Compose to remain declarative at the UI layer while maintaining a sophisticated and highly optimized font resolution system underneath.
Understanding this separation of concerns is the key to understanding how Compose transforms a simple FontFamily declaration into the concrete Typeface ultimately used by the Android rendering engine.
메타데이터
- post_id
- 1fa214b5eae7
- slug
- how-compose-separates-font-resolution-from-ui-components-1fa214b5eae7
- url
- https://medium.com/@quokkaman/how-compose-separates-font-resolution-from-ui-components-1fa214b5eae7
- canonical_url
- https://medium.com/@quokkaman/how-compose-separates-font-resolution-from-ui-components-1fa214b5eae7
- author_url
- https://medium.com/@quokkaman
- status
- ok
- fetched_at
- 2026-06-14 17:09:17