CommonMangaItem.kt 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. package eu.kanade.presentation.components
  2. import androidx.compose.foundation.background
  3. import androidx.compose.foundation.combinedClickable
  4. import androidx.compose.foundation.layout.Box
  5. import androidx.compose.foundation.layout.BoxScope
  6. import androidx.compose.foundation.layout.Column
  7. import androidx.compose.foundation.layout.Row
  8. import androidx.compose.foundation.layout.RowScope
  9. import androidx.compose.foundation.layout.aspectRatio
  10. import androidx.compose.foundation.layout.fillMaxHeight
  11. import androidx.compose.foundation.layout.fillMaxWidth
  12. import androidx.compose.foundation.layout.height
  13. import androidx.compose.foundation.layout.padding
  14. import androidx.compose.foundation.layout.size
  15. import androidx.compose.foundation.shape.RoundedCornerShape
  16. import androidx.compose.material.icons.Icons
  17. import androidx.compose.material.icons.filled.PlayArrow
  18. import androidx.compose.material3.FilledIconButton
  19. import androidx.compose.material3.Icon
  20. import androidx.compose.material3.IconButtonDefaults
  21. import androidx.compose.material3.LocalContentColor
  22. import androidx.compose.material3.MaterialTheme
  23. import androidx.compose.material3.Text
  24. import androidx.compose.material3.contentColorFor
  25. import androidx.compose.runtime.Composable
  26. import androidx.compose.runtime.CompositionLocalProvider
  27. import androidx.compose.ui.Alignment
  28. import androidx.compose.ui.Modifier
  29. import androidx.compose.ui.draw.alpha
  30. import androidx.compose.ui.draw.clip
  31. import androidx.compose.ui.graphics.Brush
  32. import androidx.compose.ui.graphics.Color
  33. import androidx.compose.ui.graphics.Shadow
  34. import androidx.compose.ui.graphics.drawscope.ContentDrawScope
  35. import androidx.compose.ui.node.DrawModifierNode
  36. import androidx.compose.ui.node.modifierElementOf
  37. import androidx.compose.ui.text.TextStyle
  38. import androidx.compose.ui.text.style.TextOverflow
  39. import androidx.compose.ui.unit.dp
  40. import androidx.compose.ui.unit.sp
  41. import eu.kanade.presentation.util.selectedBackground
  42. object CommonMangaItemDefaults {
  43. val GridHorizontalSpacer = 4.dp
  44. val GridVerticalSpacer = 4.dp
  45. const val BrowseFavoriteCoverAlpha = 0.34f
  46. }
  47. private val ContinueReadingButtonSize = 38.dp
  48. private const val GridSelectedCoverAlpha = 0.76f
  49. /**
  50. * Layout of grid list item with title overlaying the cover.
  51. * Accepts null [title] for a cover-only view.
  52. */
  53. @Composable
  54. fun MangaCompactGridItem(
  55. isSelected: Boolean = false,
  56. title: String? = null,
  57. coverData: eu.kanade.domain.manga.model.MangaCover,
  58. coverAlpha: Float = 1f,
  59. coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
  60. coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
  61. showContinueReadingButton: Boolean = false,
  62. onLongClick: () -> Unit,
  63. onClick: () -> Unit,
  64. onClickContinueReading: (() -> Unit)? = null,
  65. ) {
  66. GridItemSelectable(
  67. isSelected = isSelected,
  68. onClick = onClick,
  69. onLongClick = onLongClick,
  70. ) {
  71. MangaGridCover(
  72. cover = {
  73. MangaCover.Book(
  74. modifier = Modifier
  75. .fillMaxWidth()
  76. .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
  77. data = coverData,
  78. )
  79. },
  80. badgesStart = coverBadgeStart,
  81. badgesEnd = coverBadgeEnd,
  82. content = {
  83. if (title != null) {
  84. CoverTextOverlay(title = title, showContinueReadingButton)
  85. }
  86. },
  87. continueReadingButton = {
  88. if (showContinueReadingButton && onClickContinueReading != null) {
  89. ContinueReadingButton(onClickContinueReading)
  90. }
  91. },
  92. )
  93. }
  94. }
  95. /**
  96. * Title overlay for [MangaCompactGridItem]
  97. */
  98. @Composable
  99. private fun BoxScope.CoverTextOverlay(
  100. title: String,
  101. showContinueReadingButton: Boolean = false,
  102. ) {
  103. Box(
  104. modifier = Modifier
  105. .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
  106. .background(
  107. Brush.verticalGradient(
  108. 0f to Color.Transparent,
  109. 1f to Color(0xAA000000),
  110. ),
  111. )
  112. .fillMaxHeight(0.33f)
  113. .fillMaxWidth()
  114. .align(Alignment.BottomCenter),
  115. )
  116. val endPadding = if (showContinueReadingButton) ContinueReadingButtonSize else 8.dp
  117. GridItemTitle(
  118. modifier = Modifier
  119. .padding(start = 8.dp, top = 8.dp, end = endPadding, bottom = 8.dp)
  120. .align(Alignment.BottomStart),
  121. title = title,
  122. style = MaterialTheme.typography.titleSmall.copy(
  123. color = Color.White,
  124. shadow = Shadow(
  125. color = Color.Black,
  126. blurRadius = 4f,
  127. ),
  128. ),
  129. )
  130. }
  131. /**
  132. * Layout of grid list item with title below the cover.
  133. */
  134. @Composable
  135. fun MangaComfortableGridItem(
  136. isSelected: Boolean = false,
  137. title: String,
  138. coverData: eu.kanade.domain.manga.model.MangaCover,
  139. coverAlpha: Float = 1f,
  140. coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
  141. coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
  142. showContinueReadingButton: Boolean = false,
  143. onLongClick: () -> Unit,
  144. onClick: () -> Unit,
  145. onClickContinueReading: (() -> Unit)? = null,
  146. ) {
  147. GridItemSelectable(
  148. isSelected = isSelected,
  149. onClick = onClick,
  150. onLongClick = onLongClick,
  151. ) {
  152. Column {
  153. MangaGridCover(
  154. cover = {
  155. MangaCover.Book(
  156. modifier = Modifier
  157. .fillMaxWidth()
  158. .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
  159. data = coverData,
  160. )
  161. },
  162. badgesStart = coverBadgeStart,
  163. badgesEnd = coverBadgeEnd,
  164. continueReadingButton = {
  165. if (showContinueReadingButton && onClickContinueReading != null) {
  166. ContinueReadingButton(onClickContinueReading)
  167. }
  168. },
  169. )
  170. GridItemTitle(
  171. modifier = Modifier.padding(4.dp),
  172. title = title,
  173. style = MaterialTheme.typography.titleSmall,
  174. )
  175. }
  176. }
  177. }
  178. /**
  179. * Common cover layout to add contents to be drawn on top of the cover.
  180. */
  181. @Composable
  182. private fun MangaGridCover(
  183. modifier: Modifier = Modifier,
  184. cover: @Composable BoxScope.() -> Unit = {},
  185. badgesStart: (@Composable RowScope.() -> Unit)? = null,
  186. badgesEnd: (@Composable RowScope.() -> Unit)? = null,
  187. continueReadingButton: (@Composable BoxScope.() -> Unit)? = null,
  188. content: @Composable (BoxScope.() -> Unit)? = null,
  189. ) {
  190. Box(
  191. modifier = modifier
  192. .fillMaxWidth()
  193. .aspectRatio(MangaCover.Book.ratio),
  194. ) {
  195. cover()
  196. content?.invoke(this)
  197. if (badgesStart != null) {
  198. BadgeGroup(
  199. modifier = Modifier
  200. .padding(4.dp)
  201. .align(Alignment.TopStart),
  202. content = badgesStart,
  203. )
  204. }
  205. if (badgesEnd != null) {
  206. BadgeGroup(
  207. modifier = Modifier
  208. .padding(4.dp)
  209. .align(Alignment.TopEnd),
  210. content = badgesEnd,
  211. )
  212. }
  213. continueReadingButton?.invoke(this)
  214. }
  215. }
  216. @Composable
  217. private fun GridItemTitle(
  218. modifier: Modifier,
  219. title: String,
  220. style: TextStyle,
  221. ) {
  222. Text(
  223. modifier = modifier,
  224. text = title,
  225. fontSize = 12.sp,
  226. lineHeight = 18.sp,
  227. maxLines = 2,
  228. overflow = TextOverflow.Ellipsis,
  229. style = style,
  230. )
  231. }
  232. /**
  233. * Wrapper for grid items to handle selection state, click and long click.
  234. */
  235. @Composable
  236. private fun GridItemSelectable(
  237. modifier: Modifier = Modifier,
  238. isSelected: Boolean,
  239. onClick: () -> Unit,
  240. onLongClick: () -> Unit,
  241. content: @Composable () -> Unit,
  242. ) {
  243. Box(
  244. modifier = modifier
  245. .clip(MaterialTheme.shapes.small)
  246. .combinedClickable(
  247. onClick = onClick,
  248. onLongClick = onLongClick,
  249. )
  250. .selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
  251. .padding(4.dp),
  252. ) {
  253. val contentColor = if (isSelected) {
  254. MaterialTheme.colorScheme.onSecondary
  255. } else {
  256. LocalContentColor.current
  257. }
  258. CompositionLocalProvider(LocalContentColor provides contentColor) {
  259. content()
  260. }
  261. }
  262. }
  263. /**
  264. * @see GridItemSelectable
  265. */
  266. private fun Modifier.selectedOutline(
  267. isSelected: Boolean,
  268. color: Color,
  269. ): Modifier {
  270. class SelectedOutlineNode(var selected: Boolean, var color: Color) : DrawModifierNode, Modifier.Node() {
  271. override fun ContentDrawScope.draw() {
  272. if (selected) drawRect(color)
  273. drawContent()
  274. }
  275. }
  276. return this then modifierElementOf(
  277. params = isSelected.hashCode() + color.hashCode(),
  278. create = { SelectedOutlineNode(isSelected, color) },
  279. update = {
  280. it.selected = isSelected
  281. it.color = color
  282. },
  283. definitions = {
  284. name = "selectionOutline"
  285. properties["isSelected"] = isSelected
  286. properties["color"] = color
  287. },
  288. )
  289. }
  290. /**
  291. * Layout of list item.
  292. */
  293. @Composable
  294. fun MangaListItem(
  295. isSelected: Boolean = false,
  296. title: String,
  297. coverData: eu.kanade.domain.manga.model.MangaCover,
  298. coverAlpha: Float = 1f,
  299. badge: @Composable RowScope.() -> Unit,
  300. showContinueReadingButton: Boolean = false,
  301. onLongClick: () -> Unit,
  302. onClick: () -> Unit,
  303. onClickContinueReading: (() -> Unit)? = null,
  304. ) {
  305. Row(
  306. modifier = Modifier
  307. .selectedBackground(isSelected)
  308. .height(56.dp)
  309. .combinedClickable(
  310. onClick = onClick,
  311. onLongClick = onLongClick,
  312. )
  313. .padding(horizontal = 16.dp, vertical = 8.dp),
  314. verticalAlignment = Alignment.CenterVertically,
  315. ) {
  316. MangaCover.Square(
  317. modifier = Modifier
  318. .fillMaxHeight()
  319. .alpha(coverAlpha),
  320. data = coverData,
  321. )
  322. Text(
  323. text = title,
  324. modifier = Modifier
  325. .padding(horizontal = 16.dp)
  326. .weight(1f),
  327. maxLines = 2,
  328. overflow = TextOverflow.Ellipsis,
  329. style = MaterialTheme.typography.bodyMedium,
  330. )
  331. BadgeGroup(content = badge)
  332. if (showContinueReadingButton && onClickContinueReading != null) {
  333. Box {
  334. ContinueReadingButton(onClickContinueReading)
  335. }
  336. }
  337. }
  338. }
  339. @Composable
  340. private fun BoxScope.ContinueReadingButton(
  341. onClickContinueReading: () -> Unit,
  342. ) {
  343. FilledIconButton(
  344. onClick = {
  345. onClickContinueReading()
  346. },
  347. modifier = Modifier
  348. .size(ContinueReadingButtonSize)
  349. .padding(3.dp)
  350. .align(Alignment.BottomEnd),
  351. shape = MaterialTheme.shapes.small,
  352. colors = IconButtonDefaults.filledIconButtonColors(
  353. containerColor = MaterialTheme.colorScheme.primaryContainer,
  354. contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer),
  355. ),
  356. ) {
  357. Icon(
  358. imageVector = Icons.Filled.PlayArrow,
  359. contentDescription = "",
  360. modifier = Modifier
  361. .size(15.dp),
  362. )
  363. }
  364. }