简述
此Demo是参考Google Github其中一个Demo而完成,涉及的内容并不复杂,主要是为了熟悉Compose编码习惯,其次参考官方的代码,可以有利于培养编程思维,仅此而已
Google Github Demo地址
效果视频
导航
总体分为A,B,C三个路由结点,A跳转B,B跳转C;其中B界面拥有底部导航栏,总共有三个子结点,其中B的子结点可以跳转C结点,也可以返回A结点,底部导航蓝栏中各元素也可以相互路由
导航结点
前三个是主体页面导航结点,后面三个是HomePage
界面底部导航栏三个子结点,其中HomePage
页面并不存在实际功能,只是作为一个入口,然后它的源点设置为子结点之一;这样当LabelPage
跳转到HomePage
界面,实际是导航到HomePage
的源点
/**
* 所有页面路由结点*/
sealed class Screen(val route:String){
object LabelPage:Screen("LabelPage")//标签兴趣页
object HomePage:Screen("HomePage")//首页,底部导航栏包含三个子页面
object DetailPage:Screen("DetailPage")//内容详情页
object CoursePage:Screen("HomePage/CoursePage")//底部导航栏-课程内容页
object FeaturePage:Screen("HomePage/FeaturePage")//底部导航栏-推荐内容页
object SearchPage:Screen("HomePage/SearchPage")//底部导航栏-搜索页
}
路线图
以下构建了三个结点之间的导航路线,由于其中HomePage
结点是拥有底部导航栏界面,并没有实际作用,然后通过navigation
在它的内部又构建了三个子结点,使用的都是同一个navHostController
,同样都在同一个NavHost
中
@Composable
fun NavigationGraph(
navHostController: NavHostController,
startDestination: String = Screen.LabelPage.route,
modifier: Modifier = Modifier,
finishActivity:()->Unit
){
val actions = MainAction()
NavHost(navController = navHostController, startDestination = startDestination){
/**
* 标签兴趣选择页面*/
composable(Screen.LabelPage.route){
BackHandler {
finishActivity()
}
LabelPage(){
actions.toHomePage(navHostController)
}
}
/**
* route:代表外面一层导航结点
* startDestination:代表底部导航栏中结点起始页*/
navigation(route = Screen.HomePage.route,startDestination = Screen.FeaturePage.route){
navigationSubPage(navHostController = navHostController, modifier = modifier,actions)
}
/**
* 内容详情页面*/
composable(
Screen.DetailPage.route+"?id={id}",
arguments = listOf(
navArgument(name = "id")
{
type = NavType.LongType
defaultValue = -1L
}
)
){
DetailPage(
onBack = {
actions.back(navHostController)
},
onNavigation = {
actions.toDetail(navHostController,it)
}
)
}
}
}
下面三个结点为底部导航栏包含的子结点,也就是HomePage
页面的子结点,构建与上述一致
/**
* 底部导航栏子页面路由结点*/
fun NavGraphBuilder.navigationSubPage(navHostController: NavHostController,modifier: Modifier,action: MainAction){
composable(Screen.CoursePage.route){
CoursePage(modifier){
action.toDetail(navHostController,it)
}
}
composable(Screen.FeaturePage.route){
FeaturePage(modifier){
action.toDetail(navHostController,it)
}
}
composable(Screen.SearchPage.route){
SearchPage(modifier)
}
}
底部导航栏
其中最重要的代码如下,通过判断当前节点是否属于底部导航栏结点之一,如果属于就构建底部导航栏,否则不构建;在一开始接触compose navigtion
时,就出现过糗事,当时想要从拥有底部导航栏的界面跳转的一个新的界面,然后跳转的新页面也存在底部导航栏(不想它显示),然后当时我的办法是构建两个NavHostController
,绑定两个不同NavHost
,虽然这样能够解决问题,但是两个NavHostController
之间导航切换,实在过于繁琐;
val route = tabs.map { it.route }
if (currentRoute in route){
BottomNavigation(...)
}
将底部导航栏的元素结点通过遍历进行一一构建BottomNavigationItem
,然后从外部传入NavHostController
,完成内部结点导航
@Composable
fun bottomNavBar(navHostController: NavHostController,tabs: Array<NavElement> = NavElement.values()){
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
/**
* 关键部分
* 只有当前路由结点属于底部导航栏列表元素中其中一个才显示底部导航栏*/
val route = tabs.map { it.route }
if (currentRoute in route){
BottomNavigation(
Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars.add(WindowInsets(bottom = 56.dp))),
backgroundColor = OWLTheme.colors.bottomBar
) {
tabs.forEach {
BottomNavigationItem(
icon = {Icon(painter = painterResource(id = it.icon), contentDescription = it.route)},
label = { Text(text = stringResource(id = it.title))},
selected = currentRoute == it.route,
alwaysShowLabel = false,
selectedContentColor = OWLTheme.colors.selectIcon,
unselectedContentColor = OWLTheme.colors.unselectIcon,
modifier = Modifier.navigationBarsPadding(),
onClick = {
navHostController.navigate(it.route){
navHostController.graph.startDestinationRoute?.let { route->
popUpTo(route){saveState = true}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
/**
* 底部导航栏元素*/
enum class NavElement(
@StringRes val title:Int,
val route:String,
@DrawableRes val icon:Int
){
Course(R.string.my_courses,Screen.CoursePage.route,R.drawable.ic_grain),
Feature(R.string.featured,Screen.FeaturePage.route,R.drawable.ic_featured),
Search(R.string.search,Screen.SearchPage.route,R.drawable.ic_search)
}
使用
最后直接在最外层页面,也就是Activity起点通过插槽Scaffold
添加bottomBar
,因为在bottomBar
构建时,已经通过判断,页面是否构建底部导航栏了,所以可以直接在初始页面进行构建
val navHostController = rememberNavController()
Scaffold(
bottomBar = {bottomNavBar(navHostController = navHostController)},
modifier = Modifier.fillMaxSize()
) { paddingValues ->
NavigationGraph(
navHostController = navHostController,
modifier = Modifier.padding(paddingValues),
finishActivity = finishActivity
)
}
标签页
状态切换
所有标签通过LazyHorizontalGrid
构建而成,分为3行,每一个Item拥有两种状态,被选中和为未选中,其中被选中的Item会在图片上层覆盖一层蒙层
两个Boolean状态变量用于监听toggleable
值变化,labelStyle
通过select
的值获取两套不一样的参数,也就是点击和未点击的变化量
val (select,onSelect) = remember { mutableStateOf(false) }
val labelStyle = labelChangeStyle(select)
两套不同的参数内容,
- 第一个参数:圆角角度
- 第二个参数:透明度
- 第三个参数:比例(可无)
/**
* label选中和为选中样式数值*/
fun labelChangeStyle(flag: Boolean):LabelStyle{
return when(flag){
false ->{
LabelStyle(0.dp,0f,0.6f)
}
true -> {
LabelStyle(20.dp,0.8f,1f)
}
}
}
单个Item的代码如下,Surface
绑定参数内容的radius
,并只设置成左上角,然后将Row
添加toggleable
点击事件,并绑定上述两个Boolean状态值,然后通过状态值是否为true
,判断是否显示蒙层,因为select
为mutableStateOf
修饰的变量,当它的值变化后,系统会进行重组
,然后在其引用出进行重绘;
网络图片通过Coil
库的AsyncImage
组件实现,
@Composable
fun LabelGridItem(bean:LabelModel){
val (select,onSelect) = remember { mutableStateOf(false) }
val labelStyle = labelChangeStyle(select)
Surface(
modifier = Modifier.padding(4.dp),
shape = RoundedCornerShape(topStart = labelStyle.radius)
) {
Row(
modifier = Modifier.toggleable(value = select, onValueChange = onSelect)
) {
Box {
AsyncImage(
model = bean.imageUrl,
contentDescription = bean.name,
placeholder = ColorPainter(Color.Gray.copy(alpha = 0.6f)),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(72.dp)
.aspectRatio(1f)
)
/**
* 是否被选中*/
if (select) {
Surface(
color = pink500.copy(alpha = labelStyle.alpha),
modifier = Modifier.matchParentSize()
) {
Icon(
imageVector = Icons.Filled.Done,
contentDescription = null,
tint = OWLTheme.colors.selectIcon.copy(
alpha = labelStyle.alpha
),
modifier = Modifier
.wrapContentSize()
.scale(labelStyle.scale)
)
}
}
}
Column {
Text(
text = bean.name,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 8.dp
)
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_grain),
contentDescription = null,
modifier = Modifier
.padding(start = 16.dp)
.size(12.dp)
)
Text(
text = "${bean.number}",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
}
FeaturePage
构建
所有Model数据通过LazyVerticalGrid
列表构建,单个Item通过ConstraintLayout
进行组合
@Composable
private fun featureGridItem(
bean: FeatureBean,
onNavigation: (Long) -> Unit
){
ConstraintLayout(
modifier = Modifier
.background(OWLTheme.colors.detailBackground)
.clickable {
onNavigation(bean.id)
}
) {
val (imageRef,iconRef,titleRef,contentRef,numIconRef,numTextRef) = createRefs()
AsyncImage(
model = bean.thumbUrl,
contentDescription = bean.name,
contentScale = ContentScale.Crop,
placeholder = ColorPainter(Color.Gray.copy(alpha = 0.6f)),
modifier = Modifier
.aspectRatio(4f / 3f)
.constrainAs(imageRef) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
}
)
Box(
modifier = Modifier
.size(38.dp)
.background(white, shape = CircleShape)
.padding(2.dp)
.border(1.dp, OWLTheme.colors.homeBackground, CircleShape)
.constrainAs(iconRef) {
centerHorizontallyTo(parent)
top.linkTo(imageRef.bottom, (-19).dp)
}
) {
AsyncImage(
model = bean.instructor,
contentDescription = bean.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
)
}
Text(
text = bean.subject.uppercase(),
style = MaterialTheme.typography.overline,
color = OWLTheme.colors.homeBackground,
modifier = Modifier
.padding(top = 16.dp, bottom = 16.dp)
.constrainAs(titleRef) {
centerHorizontallyTo(parent)
top.linkTo(iconRef.bottom)
}
)
Text(
text = bean.name,
style = MaterialTheme.typography.subtitle1,
color = OWLTheme.colors.primaryTitle,
textAlign = TextAlign.Center,
modifier = Modifier
.constrainAs(contentRef){
centerHorizontallyTo(parent)
top.linkTo(titleRef.bottom)
}
)
val center = createGuidelineFromStart(0.5f)
Icon(
imageVector = Icons.Default.OndemandVideo,
contentDescription = "watch",
tint = OWLTheme.colors.homeBackground,
modifier = Modifier
.size(16.dp)
.constrainAs(numIconRef) {
end.linkTo(center)
centerVerticallyTo(numTextRef)
}
)
Text(
text = "${bean.steps}",
style = MaterialTheme.typography.subtitle2,
color = OWLTheme.colors.homeBackground,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 16.dp, bottom = 16.dp, start = 4.dp)
.constrainAs(numTextRef) {
start.linkTo(center)
top.linkTo(contentRef.bottom)
}
)
}
}
CoursePage
实现
整个列表由LazyColumn
构建,单个Item由ConstraintLayout
组合,其中每个Item的对于左侧空出的宽度,奇数与偶数分别为两个常量,然后单个Item通过padding
进行空出;其中modifier
每个扩展函数的先后顺序也会有不同的变化,如果padding
放在前方,则如上图所示,被当作magin
使用,因为在宽度为声明之前,先声明padding
,此时之后声明的宽度或高度是被padding
影响之后的大小;反之,如果宽度和高度定义在前,padding
定义在后,此时padding
发挥本职作用,偏移定义的内边距
modifier = Modifier
.padding(start = spacerWidth)
.height(100.dp)
.fillMaxWidth()
.background(OWLTheme.colors.detailBackground, shape = RoundedCornerShape(topStart = 20.dp))
在ConstraintLayout
中,通过建立一条基准线val center = createGuidelineFromTop(0.5f)
,基准线有上下左右四个方位和绝对位置等,用于切割某一大小,例如传入0.5f,则代表引用的两个组件各占一半,以此类推
@Composable
private fun courseItem(
spacerWidth: Dp,
bean: FeatureBean,
onNavigation:(Long)-> Unit
){
ConstraintLayout(
modifier = Modifier
.padding(start = spacerWidth)
.height(100.dp)
.fillMaxWidth()
.background(OWLTheme.colors.detailBackground, shape = RoundedCornerShape(topStart = 20.dp))
.clickable { onNavigation(bean.id) }
) {
val (imgRef,nameRef,iconRef,epiRef) = createRefs()
AsyncImage(
model = bean.thumbUrl,
contentDescription = bean.name,
placeholder = ColorPainter(Color.Gray.copy(alpha = 0.6f)),
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 20.dp))
.constrainAs(imgRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
)
val center = createGuidelineFromTop(0.5f)
Text(
text = bean.name,
color = OWLTheme.colors.primaryTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.subtitle1,
modifier = Modifier
.constrainAs(nameRef) {
bottom.linkTo(center,5.dp)
start.linkTo(imgRef.end,16.dp)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
}
)
Icon(
imageVector = Icons.Default.OndemandVideo,
contentDescription = "Watch",
tint = OWLTheme.colors.homeBackground,
modifier = Modifier
.size(16.dp)
.constrainAs(iconRef) {
top.linkTo(center,5.dp)
start.linkTo(imgRef.end,16.dp)
}
)
Text(
text = stringResource(
id = com.franz.owl.R.string.course_step_steps,
bean.step,
bean.steps
),
color = OWLTheme.colors.homeBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.subtitle2,
modifier = Modifier
.constrainAs(epiRef) {
top.linkTo(iconRef.top)
bottom.linkTo(iconRef.bottom)
start.linkTo(iconRef.end,4.dp)
}
)
}
}
搜索
ViewModel
其中_state
监听的是列表数据源,_edit
监听的是输入框的内容,在初始化处对_state
进行赋值,然后onEvent
方法用于监听View部分的输入框的变化,然后通过输入框传过来的值通过filter
进行过滤,然后将符合条件的数据通过浅拷贝
重新给_state
赋值,外部绑定_state
的组件,因为_state
发生变化,外面组件也会相应进行重组
class SearchViewModel: ViewModel() {
private val _state = mutableStateOf(LabelBean())
val state:State<LabelBean> = _state
private val _edit = mutableStateOf(SearchModel(
hint = "input some words..."
))
val edit:State<SearchModel> = _edit
init {
_state.value = state.value.copy(
labelList = labels
)
}
fun onEvent(key: String){
_edit.value = edit.value.copy(
text = key
)
_state.value = state.value.copy(
labelList = labels.filter {
it.name.contains(key,true)
}
)
}
}
View
在SearchBar
的输入框中不断返回当前内容,然后执行onEvent
,不断改变其值;在列表处绑定viewModel
中的列表状态变量,随它的变化而重组
@Composable
fun SearchPage(
modifier: Modifier = Modifier,
viewModel: SearchViewModel = viewModel()
){
val keys = viewModel.state.value.labelList
val key = viewModel.edit.value
Column(
modifier = Modifier
.fillMaxSize()
.background(OWLTheme.colors.homeBackground)
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 66.dp)
.navigationBarsPadding()
) {
SearchBar(key.text,key.hint){
viewModel.onEvent(it)
}
Spacer(modifier = Modifier.height(15.dp))
SearchList(keys)
}
}
@Composable
private fun SearchBar(
text: String,
hint: String,
onValueChange: (String)->Unit
){
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "search",
tint = white,
)
Spacer(modifier = Modifier.width(10.dp))
BasicTextField(
value = text,
textStyle = MaterialTheme.typography.subtitle1.copy(
color = white
),
onValueChange = {onValueChange(it)},
singleLine = true,
cursorBrush = SolidColor(white)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchList(keys: List<LabelModel>){
LazyColumn(
verticalArrangement = Arrangement.spacedBy(15.dp)
){
items(keys.size){
Text(
text = keys[it].name,
color = white,
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.animateItemPlacement()
)
}
}
}
详情页
Detail
此页面分为两个界面,由Box
组件进行组合,通过底部FAB
按钮进行后面那个页面是否显示,使用AnimatedVisibility
组件包裹Lesson页,并为其设置了入场和退出动画;其中BackHandler
用于拦截系统导航栏返回按钮点击事件,当位于Lesson页面时,点击系统导航栏返回按钮,则返回Describe页
@Composable
fun DetailPage(
viewModel: DetailViewModel = viewModel(),
onBack: ()->Unit,
onNavigation:(Long)->Unit
){
val lessonState = remember { mutableStateOf(false) }
val bean = viewModel.state.value//获取详情页数据
val scope = rememberCoroutineScope()//协程
/**
* 拦截底部导航栏退出按钮点击事件
* 如果LessonPage页为展开状态,则关闭,LessonPage
* 否则退出详情页*/
BackHandler(
enabled = lessonState.value) {
scope.launch { lessonState.value = false }
}
Box() {
/**详情页*/
DescribePage(bean, onBack = onBack, onNavigation = onNavigation)
/**SheetButton,用于控制LessonPage的显示与隐藏*/
sheetBtnView(modifier = Modifier.align(Alignment.BottomEnd)){
scope.launch {
lessonState.value = it
}
}
AnimatedVisibility(
visible = lessonState.value,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
/**Lesson页*/
LessonPage(bean){
lessonState.value = it
}
}
}
}
Describe
组件通过ConstraintLayout
进行组合,顶部图片和顶部导航栏重合,中间为详细内容,底部为推荐相关数据列表;
如果Text需要显示string.xml文件的内容可以通过stringResource
进行引用,如果文件的内容字符串需要传入数字或者字符,可以通过下列方式进行使用,具体参数由vararg
可多变数量参数修饰
text = stringResource(
id = R.string.course_step_steps,
bean.step,
bean.steps
)
由于底部整个布局高度超过一个屏幕最大高度,导致底部横向列表数据无法显示,故而通过 verticalScroll(rememberScrollState())
进行竖向滑动
/**
* 内容详情页
* 用于展示相关内容*/
@Composable
fun DescribePage(
bean: FeatureBean,
onNavigation:(Long)->Unit,
onBack: () -> Unit
){
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(OWLTheme.colors.detailBackground)
.verticalScroll(rememberScrollState())
) {
val (appBarRef,imgRef,iconRef,nameRef,titleRef, contentRef,dividerRef,
tipOneRef,tipTwoRef,contentListRef) = createRefs()
AsyncImage(
model = bean.thumbUrl,
contentDescription = bean.name,
placeholder = ColorPainter(Color.Gray.copy(alpha = 0.6f)),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.constrainAs(imgRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
AppBar(
modifier = Modifier.constrainAs(appBarRef){
top.linkTo(parent.top,20.dp)
start.linkTo(parent.start,20.dp)
}) { onBack() }
Box(
modifier = Modifier
.size(38.dp)
.background(white, shape = CircleShape)
.padding(2.dp)
.border(1.dp, OWLTheme.colors.homeBackground, CircleShape)
.constrainAs(iconRef) {
centerHorizontallyTo(parent)
top.linkTo(imgRef.bottom, (-19).dp)
}
) {
AsyncImage(
model = bean.instructor,
contentDescription = bean.subject,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
)
}
Text(
text = bean.subject,
color = Color.Red,
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.constrainAs(nameRef) {
top.linkTo(iconRef.bottom, 16.dp)
centerHorizontallyTo(parent)
}
)
Text(
text = bean.name,
color = OWLTheme.colors.primaryTitle,
style = MaterialTheme.typography.h4,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.constrainAs(titleRef) {
top.linkTo(nameRef.bottom, 16.dp)
centerHorizontallyTo(parent)
}
)
Text(
text = stringResource(id = R.string.course_desc),
color = OWLTheme.colors.primaryContent,
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.constrainAs(contentRef) {
top.linkTo(titleRef.bottom, 20.dp)
start.linkTo(parent.start)
}
)
Divider(
color = OWLTheme.colors.primaryContent.copy(alpha = 0.6f),
thickness = 1.dp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 20.dp)
.constrainAs(dividerRef) {
top.linkTo(contentRef.bottom)
start.linkTo(parent.start)
}
)
Text(
text = stringResource(id = R.string.what_you_ll_need),
color = OWLTheme.colors.primaryTitle,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.constrainAs(tipOneRef) {
top.linkTo(dividerRef.bottom)
start.linkTo(parent.start)
}
)
Text(
text = stringResource(id = R.string.needs),
color = OWLTheme.colors.primaryContent,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.constrainAs(tipTwoRef) {
top.linkTo(tipOneRef.bottom, 20.dp)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
recommendContentList(
onNavigation = onNavigation,
modifier = Modifier.constrainAs(contentListRef){
top.linkTo(tipTwoRef.bottom,20.dp)
start.linkTo(parent.start)
}
)
}
}
Lesson
此页面用于展示Decribe页面相关内容,数据为静态数据,仅作为展示,通过顶部标题栏返回按钮的点击事件,改变上述AnimatedVisibility
所绑定的状态变量值,然后进行重组,使其隐藏
@Composable
fun LessonPage(
bean: FeatureBean,
onClick: (Boolean) -> Unit)
{
Column(
modifier = Modifier
.fillMaxSize()
.background(pink500)
.statusBarsPadding()
.navigationBarsPadding()
.padding(start = 10.dp, end = 10.dp, bottom = 20.dp)
) {
LessonAppBar(bean.name, onClick = onClick)
Spacer(modifier = Modifier.height(20.dp))
LessonList()
}
}
尾
此Demo还增加了沉浸式标题栏、SplashScreen界面、主题切换等功能,由于篇幅问题,在此不予贴出,有意者,可点击下述项目链接进行访问
Gitte
https://gitee.com/FranzLiszt1847/owl