How to build a markdown-like text editor with Jetpack Compose

Donald Isoe
4 min readJul 25, 2024

--

In this article, we will use TextieMDLibrary to create a rich text editor for Jetpack compose

It is wise to provide tools to format text to their liking when developing apps that require user input, especially when writing essays, articles, etc.

That is why I published an easy-to-use library to help in this endeavour.

Let’s get started.

Installation of dependency

  1. Add jitpack.io to settings.gradle.kts
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Add this dependency
maven("jitpack.io")
}
}
  1. Add the dependency to app-level build.gradle.kts
implementation("com.github.donald-okara:TextieMDLibrary:1.0.4")

Check the jitpack package for the last stable version.

This library uses a visual transformation to format text so for it to work, you need to use a text composable (textFields) with the “visualTransformation” parameter. I am quite partial to BasicTextField since it has no visible borders and adding the “.fillMaxSize” modifier can make it take the whole page.

First, let's make sure the keyboard doesn’t cover the screen.

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {

//Add this to make components move up when keyboard is visible
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)){ view, insets ->
val bottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
view.updatePadding(bottom = bottom)
insets
}

super.onCreate(savedInstanceState)
setContent {
TextieMDTheme {
VisualTransformedTextField()
}
}
}
}
//Imports needed for this app

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FormatBold
import androidx.compose.material.icons.filled.FormatItalic
import androidx.compose.material.icons.filled.FormatStrikethrough
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.textiemd.ui.theme.TextieMDTheme
import com.example.textiemdlibrary.AnnotationsManager
import com.example.textiemdlibrary.TextEditorVisualTransformer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BasicTextEditor(modifier: Modifier = Modifier){
var text by remember { mutableStateOf(TextFieldValue("")) }
val visualTransformation = remember { TextEditorVisualTransformer() }
val coroutineScope = rememberCoroutineScope()
val bringIntoViewRequester = BringIntoViewRequester()

Column {
Box(
modifier = modifier
.padding(16.dp)
) {
if (text.text.isEmpty()) {
Text(
text = "Tell your story...",
style = MaterialTheme.typography.bodyMedium.copy(color = Color.Gray)
)
}

BasicTextField(
modifier = modifier
.fillMaxWidth()
.onFocusEvent { //This moves the BasicTextField to leave space for the keyboard
if (it.isFocused || it.hasFocus) {
coroutineScope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
value = text,
onValueChange = { text = it },
visualTransformation = visualTransformation,
textStyle = MaterialTheme.typography.bodyLarge
)


}

Spacer(modifier = modifier.padding(8.dp))

FormatButtons(
modifier = modifier
.bringIntoViewRequester(bringIntoViewRequester),//Moves with keyboard
text = text,
onTextChange = { text = it }
)
}

}

@Composable
fun FormatButtons(
modifier: Modifier = Modifier,
text: TextFieldValue,
onTextChange: (TextFieldValue) -> Unit
){
val annotationsManager = remember { AnnotationsManager() }
val scope = rememberCoroutineScope()
val selection = text.selection
val isBold = annotationsManager.isBold(text.text, selection.start, selection.end)
val isItalics = annotationsManager.isItalics(text.text, selection.start, selection.end)
val isStrikethrough = annotationsManager.isStrikethrough(text.text, selection.start, selection.end)
val isMonospace = annotationsManager.isMonospace(text.text, selection.start, selection.end)


Row(
modifier = modifier
.fillMaxWidth()
.padding(all = 10.dp)
.padding(bottom = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ControlWrapper(
isSelected = isBold,
onClick = {
scope.launch(Dispatchers.Default) {
val updatedText = annotationsManager.applyBold(
text = text.text,
selectionStart = selection.start,
selectionEnd = selection.end
)
scope.launch(Dispatchers.Main) {
onTextChange(text.copy(text = updatedText))
}
}
}
) {
Icon(
imageVector = Icons.Default.FormatBold,
contentDescription = "Bold",
tint = if (isBold) MaterialTheme.colorScheme.onTertiaryContainer else LocalContentColor.current
)
}


ControlWrapper(
isSelected = isItalics,
onClick = {
scope.launch(Dispatchers.Default) {
val updatedText = annotationsManager.applyItalics(
text = text.text,
selectionStart = selection.start,
selectionEnd = selection.end
)
scope.launch(Dispatchers.Main) {
onTextChange(text.copy(text = updatedText))
}
}
}
) {
Icon(
imageVector = Icons.Default.FormatItalic,
contentDescription = "Italics",
tint = if (isBold) MaterialTheme.colorScheme.onTertiaryContainer else LocalContentColor.current
)
}

ControlWrapper(
isSelected = isStrikethrough,
onClick = {
scope.launch(Dispatchers.Default) {
val updatedText = annotationsManager.applyStrikethrough(
text = text.text,
selectionStart = selection.start,
selectionEnd = selection.end
)
scope.launch(Dispatchers.Main) {
onTextChange(text.copy(text = updatedText))
}
}
}
) {
Icon(
imageVector = Icons.Default.FormatStrikethrough,
contentDescription = "Strikethrough",
tint = if (isBold) MaterialTheme.colorScheme.onTertiaryContainer else LocalContentColor.current
)
}

ControlWrapper(
isSelected = isMonospace,
onClick = {
scope.launch(Dispatchers.Default) {
val updatedText = annotationsManager.applyMonospace(
text = text.text,
selectionStart = selection.start,
selectionEnd = selection.end
)
scope.launch(Dispatchers.Main) {
onTextChange(text.copy(text = updatedText))
}
}
}
) {
Icon(
imageVector = Icons.Default.Code,
contentDescription = "Monospace",
tint = if (isBold) MaterialTheme.colorScheme.onTertiaryContainer else LocalContentColor.current
)
}
}

}

//The formating button wrapper
@Composable
fun ControlWrapper(
modifier: Modifier = Modifier,
isSelected: Boolean,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
//.clip(CircleShape)
.size(48.dp)
.clickable { onClick() }
.background(
if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent,
)
.border(
width = 1.dp,
color = Color.LightGray,
shape = CircleShape
)
.padding(all = 8.dp),
contentAlignment = Alignment.Center
) {
content()
}
}

These are the annotations and transformations.

  • Bold: **bold**
  • Italics: ~~italic~~
  • Strikethrough: --strikethrough--
  • Monospace: `monospace`
  • Headings: # Heading
  • Hashtags: #hashtag

The buttons when clicked surround the text with their respective annotation above.

Check the documentation for more details on how to use the library.

Happy coding.

--

--