commit
1bfd9fa755
@ -0,0 +1,93 @@ |
||||
package io.legado.app.ui.book.searchContent |
||||
|
||||
import android.os.Bundle |
||||
import android.view.Menu |
||||
import androidx.appcompat.widget.SearchView |
||||
import androidx.core.view.isGone |
||||
import androidx.fragment.app.Fragment |
||||
import androidx.fragment.app.FragmentManager |
||||
import androidx.fragment.app.FragmentPagerAdapter |
||||
import io.legado.app.R |
||||
import io.legado.app.base.VMBaseActivity |
||||
import io.legado.app.lib.theme.ATH |
||||
import io.legado.app.lib.theme.accentColor |
||||
import io.legado.app.lib.theme.primaryTextColor |
||||
import io.legado.app.utils.getViewModel |
||||
import io.legado.app.utils.gone |
||||
import io.legado.app.utils.visible |
||||
import kotlinx.android.synthetic.main.activity_chapter_list.* |
||||
import kotlinx.android.synthetic.main.view_tab_layout.* |
||||
|
||||
|
||||
class SearchListActivity : VMBaseActivity<SearchListViewModel>(R.layout.activity_search_list) { |
||||
// todo: 完善搜索界面UI |
||||
override val viewModel: SearchListViewModel |
||||
get() = getViewModel(SearchListViewModel::class.java) |
||||
|
||||
private var searchView: SearchView? = null |
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) { |
||||
tab_layout.isTabIndicatorFullWidth = false |
||||
tab_layout.setSelectedTabIndicatorColor(accentColor) |
||||
intent.getStringExtra("bookUrl")?.let { |
||||
viewModel.initBook(it) { |
||||
view_pager.adapter = TabFragmentPageAdapter(supportFragmentManager) |
||||
tab_layout.setupWithViewPager(view_pager) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { |
||||
menuInflater.inflate(R.menu.search_view, menu) |
||||
val search = menu.findItem(R.id.menu_search) |
||||
searchView = search.actionView as SearchView |
||||
ATH.setTint(searchView!!, primaryTextColor) |
||||
searchView?.maxWidth = resources.displayMetrics.widthPixels |
||||
searchView?.onActionViewCollapsed() |
||||
searchView?.setOnCloseListener { |
||||
tab_layout.visible() |
||||
//to do clean |
||||
false |
||||
} |
||||
searchView?.setOnSearchClickListener { tab_layout.gone() } |
||||
searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { |
||||
override fun onQueryTextSubmit(query: String): Boolean { |
||||
if (viewModel.lastQuery != query){ |
||||
viewModel.startContentSearch(query) |
||||
} |
||||
return false |
||||
} |
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean { |
||||
|
||||
return false |
||||
} |
||||
}) |
||||
return super.onCompatCreateOptionsMenu(menu) |
||||
} |
||||
|
||||
private inner class TabFragmentPageAdapter(fm: FragmentManager) : |
||||
FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { |
||||
override fun getItem(position: Int): Fragment { |
||||
return SearchListFragment() |
||||
} |
||||
|
||||
override fun getCount(): Int { |
||||
return 1 |
||||
} |
||||
|
||||
override fun getPageTitle(position: Int): CharSequence? { |
||||
return "Search" |
||||
} |
||||
|
||||
} |
||||
|
||||
override fun onBackPressed() { |
||||
if (tab_layout.isGone) { |
||||
searchView?.onActionViewCollapsed() |
||||
tab_layout.visible() |
||||
} else { |
||||
super.onBackPressed() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,51 @@ |
||||
package io.legado.app.ui.book.searchContent |
||||
|
||||
import android.content.Context |
||||
import android.os.Build |
||||
import android.text.Html |
||||
import android.util.Log |
||||
import android.view.View |
||||
import androidx.annotation.RequiresApi |
||||
import androidx.core.text.HtmlCompat |
||||
import io.legado.app.R |
||||
import io.legado.app.base.adapter.ItemViewHolder |
||||
import io.legado.app.base.adapter.SimpleRecyclerAdapter |
||||
import io.legado.app.data.entities.BookChapter |
||||
import io.legado.app.help.BookHelp |
||||
import io.legado.app.lib.theme.accentColor |
||||
import io.legado.app.utils.getCompatColor |
||||
import io.legado.app.utils.visible |
||||
import kotlinx.android.synthetic.main.item_bookmark.view.* |
||||
import kotlinx.android.synthetic.main.item_search_list.view.* |
||||
import org.jetbrains.anko.sdk27.listeners.onClick |
||||
|
||||
class SearchListAdapter(context: Context, val callback: Callback) : |
||||
SimpleRecyclerAdapter<SearchResult>(context, R.layout.item_search_list) { |
||||
|
||||
val cacheFileNames = hashSetOf<String>() |
||||
|
||||
override fun convert(holder: ItemViewHolder, item: SearchResult, payloads: MutableList<Any>) { |
||||
with(holder.itemView) { |
||||
val isDur = callback.durChapterIndex() == item.chapterIndex |
||||
if (payloads.isEmpty()) { |
||||
tv_search_result.text = item.parseText(item.presentText) |
||||
if (isDur){ |
||||
tv_search_result.paint.isFakeBoldText = true |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun registerListener(holder: ItemViewHolder) { |
||||
holder.itemView.onClick { |
||||
getItem(holder.layoutPosition)?.let { |
||||
callback.openSearchResult(it) |
||||
} |
||||
} |
||||
} |
||||
|
||||
interface Callback { |
||||
fun openSearchResult(searchResult: SearchResult) |
||||
fun durChapterIndex(): Int |
||||
} |
||||
} |
@ -0,0 +1,239 @@ |
||||
package io.legado.app.ui.book.searchContent |
||||
|
||||
import android.annotation.SuppressLint |
||||
import android.app.Activity.RESULT_OK |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.View |
||||
import androidx.lifecycle.LiveData |
||||
import com.hankcs.hanlp.HanLP |
||||
import io.legado.app.App |
||||
import io.legado.app.R |
||||
import io.legado.app.base.VMBaseFragment |
||||
import io.legado.app.constant.EventBus |
||||
import io.legado.app.data.entities.Book |
||||
import io.legado.app.data.entities.BookChapter |
||||
import io.legado.app.help.AppConfig |
||||
import io.legado.app.help.BookHelp |
||||
import io.legado.app.lib.theme.bottomBackground |
||||
import io.legado.app.lib.theme.getPrimaryTextColor |
||||
import io.legado.app.service.help.ReadBook |
||||
import io.legado.app.ui.book.read.page.entities.TextPage |
||||
import io.legado.app.ui.book.read.page.provider.ChapterProvider |
||||
import io.legado.app.ui.widget.recycler.UpLinearLayoutManager |
||||
import io.legado.app.ui.widget.recycler.VerticalDivider |
||||
import io.legado.app.utils.ColorUtils |
||||
import io.legado.app.utils.getViewModelOfActivity |
||||
import io.legado.app.utils.observeEvent |
||||
import kotlinx.android.synthetic.main.fragment_search_list.* |
||||
import kotlinx.coroutines.* |
||||
import kotlinx.coroutines.Dispatchers.IO |
||||
import kotlinx.coroutines.Dispatchers.Main |
||||
import org.jetbrains.anko.sdk27.listeners.onClick |
||||
import java.util.regex.Pattern |
||||
|
||||
class SearchListFragment : VMBaseFragment<SearchListViewModel>(R.layout.fragment_search_list), |
||||
SearchListAdapter.Callback, |
||||
SearchListViewModel.SearchListCallBack{ |
||||
override val viewModel: SearchListViewModel |
||||
get() = getViewModelOfActivity(SearchListViewModel::class.java) |
||||
|
||||
lateinit var adapter: SearchListAdapter |
||||
private lateinit var mLayoutManager: UpLinearLayoutManager |
||||
private var searchResultCounts = 0 |
||||
private var durChapterIndex = 0 |
||||
private var searchResultList: MutableList<SearchResult> = mutableListOf() |
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { |
||||
viewModel.searchCallBack = this |
||||
val bbg = bottomBackground |
||||
val btc = requireContext().getPrimaryTextColor(ColorUtils.isColorLight(bbg)) |
||||
ll_search_base_info.setBackgroundColor(bbg) |
||||
tv_current_search_info.setTextColor(btc) |
||||
iv_search_content_top.setColorFilter(btc) |
||||
iv_search_content_bottom.setColorFilter(btc) |
||||
initRecyclerView() |
||||
initView() |
||||
initBook() |
||||
} |
||||
|
||||
private fun initRecyclerView() { |
||||
adapter = SearchListAdapter(requireContext(), this) |
||||
mLayoutManager = UpLinearLayoutManager(requireContext()) |
||||
recycler_view.layoutManager = mLayoutManager |
||||
recycler_view.addItemDecoration(VerticalDivider(requireContext())) |
||||
recycler_view.adapter = adapter |
||||
} |
||||
|
||||
private fun initView() { |
||||
iv_search_content_top.onClick { mLayoutManager.scrollToPositionWithOffset(0, 0) } |
||||
iv_search_content_bottom.onClick { |
||||
if (adapter.itemCount > 0) { |
||||
mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@SuppressLint("SetTextI18n") |
||||
private fun initBook() { |
||||
launch { |
||||
|
||||
tv_current_search_info.text = "搜索结果:$searchResultCounts" |
||||
viewModel.book?.let { |
||||
initCacheFileNames(it) |
||||
durChapterIndex = it.durChapterIndex |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun initCacheFileNames(book: Book) { |
||||
launch(IO) { |
||||
adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book)) |
||||
withContext(Main) { |
||||
adapter.notifyItemRangeChanged(0, adapter.getActualItemCount(), true) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun observeLiveBus() { |
||||
observeEvent<BookChapter>(EventBus.SAVE_CONTENT) { chapter -> |
||||
viewModel.book?.bookUrl?.let { bookUrl -> |
||||
if (chapter.bookUrl == bookUrl) { |
||||
adapter.cacheFileNames.add(BookHelp.formatChapterName(chapter)) |
||||
adapter.notifyItemChanged(chapter.index, true) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@SuppressLint("SetTextI18n") |
||||
override fun startContentSearch(newText: String) { |
||||
// 按章节搜索内容 |
||||
if (!newText.isBlank()) { |
||||
adapter.clearItems() |
||||
searchResultList.clear() |
||||
searchResultCounts = 0 |
||||
viewModel.lastQuery = newText |
||||
var searchResults = listOf<SearchResult>() |
||||
launch(Main){ |
||||
App.db.bookChapterDao().getChapterList(viewModel.bookUrl).map{ chapter -> |
||||
val job = async(IO){ |
||||
if (isLocalBook || adapter.cacheFileNames.contains(BookHelp.formatChapterName(chapter))) { |
||||
searchResults = searchChapter(newText, chapter) |
||||
} |
||||
} |
||||
job.await() |
||||
if (searchResults.isNotEmpty()){ |
||||
searchResultList.addAll(searchResults) |
||||
tv_current_search_info.text = "搜索结果:$searchResultCounts" |
||||
adapter.addItems(searchResults) |
||||
searchResults = listOf<SearchResult>() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> { |
||||
val searchResults: MutableList<SearchResult> = mutableListOf() |
||||
var positions : List<Int> = listOf() |
||||
var replaceContents: List<String>? = null |
||||
var totalContents = "" |
||||
if (chapter != null){ |
||||
viewModel.book?.let { bookSource -> |
||||
val bookContent = BookHelp.getContent(bookSource, chapter) |
||||
if (bookContent != null){ |
||||
//搜索替换后的正文 |
||||
val job = async(IO) { |
||||
chapter.title = when (AppConfig.chineseConverterType) { |
||||
1 -> HanLP.convertToSimplifiedChinese(chapter.title) |
||||
2 -> HanLP.convertToTraditionalChinese(chapter.title) |
||||
else -> chapter.title |
||||
} |
||||
replaceContents = BookHelp.disposeContent( |
||||
chapter.title, |
||||
bookSource.name, |
||||
bookSource.bookUrl, |
||||
bookContent, |
||||
bookSource.useReplaceRule |
||||
) |
||||
} |
||||
job.await() |
||||
while (replaceContents == null){ |
||||
delay(100L) |
||||
} |
||||
totalContents = replaceContents!!.joinToString("") |
||||
positions = searchPosition(totalContents, query) |
||||
var count = 1 |
||||
positions.map{ |
||||
val construct = constructText(totalContents, it, query) |
||||
val result = SearchResult(index = searchResultCounts, |
||||
indexWithinChapter = count, |
||||
text = construct[1] as String, |
||||
chapterTitle = chapter.title, |
||||
query = query, |
||||
chapterIndex = chapter.index, |
||||
newPosition = construct[0] as Int, |
||||
contentPosition = it |
||||
) |
||||
count += 1 |
||||
searchResultCounts += 1 |
||||
searchResults.add(result) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return searchResults |
||||
} |
||||
|
||||
private fun searchPosition(content: String, pattern: String): List<Int> { |
||||
val position : MutableList<Int> = mutableListOf() |
||||
var index = content.indexOf(pattern) |
||||
while(index >= 0){ |
||||
position.add(index) |
||||
index = content.indexOf(pattern, index + 1); |
||||
} |
||||
return position |
||||
} |
||||
|
||||
private fun constructText(content: String, position: Int, query: String): Array<Any>{ |
||||
// 构建关键词周边文字,在搜索结果里显示 |
||||
// todo: 判断段落,只在关键词所在段落内分割 |
||||
// todo: 利用标点符号分割完整的句 |
||||
// todo: length和设置结合,自由调整周边文字长度 |
||||
val length = 20 |
||||
var po1 = position - length |
||||
var po2 = position + query.length + length |
||||
if (po1 <0) { |
||||
po1 = 0 |
||||
} |
||||
if (po2 > content.length){ |
||||
po2 = content.length |
||||
} |
||||
val newPosition = position - po1 |
||||
val newText = content.substring(po1, po2) |
||||
return arrayOf(newPosition, newText) |
||||
} |
||||
|
||||
val isLocalBook: Boolean |
||||
get() = viewModel.book?.isLocalBook() == true |
||||
|
||||
override fun openSearchResult(searchResult: SearchResult) { |
||||
|
||||
val searchData = Intent() |
||||
searchData.putExtra("index", searchResult.chapterIndex) |
||||
searchData.putExtra("contentPosition", searchResult.contentPosition) |
||||
searchData.putExtra("query", searchResult.query) |
||||
searchData.putExtra("indexWithinChapter", searchResult.indexWithinChapter) |
||||
activity?.setResult(RESULT_OK, searchData) |
||||
activity?.finish() |
||||
|
||||
|
||||
} |
||||
|
||||
override fun durChapterIndex(): Int { |
||||
return durChapterIndex |
||||
} |
||||
|
||||
} |
@ -0,0 +1,33 @@ |
||||
package io.legado.app.ui.book.searchContent |
||||
|
||||
|
||||
import android.app.Application |
||||
import io.legado.app.App |
||||
import io.legado.app.base.BaseViewModel |
||||
import io.legado.app.data.entities.Book |
||||
|
||||
class SearchListViewModel(application: Application) : BaseViewModel(application) { |
||||
var bookUrl: String = "" |
||||
var book: Book? = null |
||||
var searchCallBack: SearchListCallBack? = null |
||||
var lastQuery: String = "" |
||||
|
||||
fun initBook(bookUrl: String, success: () -> Unit) { |
||||
this.bookUrl = bookUrl |
||||
execute { |
||||
book = App.db.bookDao().getBook(bookUrl) |
||||
}.onSuccess { |
||||
success.invoke() |
||||
} |
||||
} |
||||
|
||||
fun startContentSearch(newText: String) { |
||||
searchCallBack?.startContentSearch(newText) |
||||
} |
||||
|
||||
|
||||
interface SearchListCallBack { |
||||
fun startContentSearch(newText: String) |
||||
} |
||||
|
||||
} |
@ -0,0 +1,40 @@ |
||||
package io.legado.app.ui.book.searchContent |
||||
|
||||
import android.text.Spanned |
||||
import android.util.Log |
||||
import androidx.core.text.HtmlCompat |
||||
import io.legado.app.ui.book.read.page.entities.TextPage |
||||
|
||||
data class SearchResult( |
||||
var index: Int = 0, |
||||
var indexWithinChapter: Int = 0, |
||||
var text: String = "", |
||||
var chapterTitle: String = "", |
||||
val query: String, |
||||
var pageSize: Int = 0, |
||||
var chapterIndex: Int = 0, |
||||
var pageIndex: Int = 0, |
||||
var newPosition: Int = 0, |
||||
var contentPosition: Int =0 |
||||
) { |
||||
val presentText: String |
||||
get(){ |
||||
return colorPresentText(newPosition, query, text) + |
||||
"<font color=#0000ff>($chapterTitle)</font>" |
||||
} |
||||
|
||||
fun colorPresentText(position: Int, center: String, targetText: String): String{ |
||||
val sub1 = text.substring(0, position) |
||||
val sub2 = text.substring(position + center.length, targetText.length) |
||||
return "<font color=#000000>$sub1</font>" + |
||||
"<font color=#ff0000>$center</font>" + |
||||
"<font color=#000000>$sub2</font>" |
||||
} |
||||
|
||||
fun parseText(targetText: String): Spanned { |
||||
return HtmlCompat.fromHtml(targetText, HtmlCompat.FROM_HTML_MODE_LEGACY) |
||||
} |
||||
|
||||
|
||||
|
||||
} |
@ -0,0 +1,19 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:orientation="vertical" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"> |
||||
|
||||
<io.legado.app.ui.widget.TitleBar |
||||
android:id="@+id/title_bar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
app:contentLayout="@layout/view_tab_layout"/> |
||||
|
||||
<androidx.viewpager.widget.ViewPager |
||||
android:id="@+id/view_pager" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"/> |
||||
|
||||
</LinearLayout> |
@ -0,0 +1,66 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:descendantFocusability="blocksDescendants"> |
||||
|
||||
<io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView |
||||
android:id="@+id/recycler_view" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="0dp" |
||||
android:overScrollMode="never" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
app:layout_constraintBottom_toTopOf="@+id/ll_search_base_info" /> |
||||
|
||||
<LinearLayout |
||||
android:id="@+id/ll_search_base_info" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="36dp" |
||||
android:background="@color/background" |
||||
android:paddingLeft="10dp" |
||||
android:paddingRight="10dp" |
||||
android:elevation="5dp" |
||||
android:gravity="center_vertical" |
||||
android:orientation="horizontal" |
||||
app:layout_constraintBottom_toBottomOf="parent"> |
||||
|
||||
<TextView |
||||
android:id="@+id/tv_current_search_info" |
||||
android:layout_width="0dp" |
||||
android:layout_height="match_parent" |
||||
android:layout_weight="1" |
||||
android:background="?android:attr/selectableItemBackgroundBorderless" |
||||
android:ellipsize="middle" |
||||
android:paddingLeft="10dp" |
||||
android:paddingRight="10dp" |
||||
android:singleLine="true" |
||||
android:gravity="center_vertical" |
||||
android:textColor="@color/primaryText" |
||||
android:textSize="12sp" /> |
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView |
||||
android:id="@+id/iv_search_content_top" |
||||
android:layout_width="36dp" |
||||
android:layout_height="match_parent" |
||||
android:background="?android:attr/selectableItemBackgroundBorderless" |
||||
android:contentDescription="@string/go_to_top" |
||||
android:src="@drawable/ic_arrow_drop_up" |
||||
android:tooltipText="@string/go_to_top" |
||||
app:tint="@color/primaryText" |
||||
tools:ignore="UnusedAttribute" /> |
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView |
||||
android:id="@+id/iv_search_content_bottom" |
||||
android:layout_width="36dp" |
||||
android:layout_height="match_parent" |
||||
android:background="?android:attr/selectableItemBackgroundBorderless" |
||||
android:contentDescription="@string/go_to_bottom" |
||||
android:src="@drawable/ic_arrow_drop_down" |
||||
android:tooltipText="@string/go_to_bottom" |
||||
app:tint="@color/primaryText" |
||||
tools:ignore="UnusedAttribute" /> |
||||
</LinearLayout> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
@ -0,0 +1,19 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:orientation="vertical" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:background="?android:attr/selectableItemBackground" |
||||
android:padding="12dp"> |
||||
|
||||
<TextView |
||||
android:id="@+id/tv_search_result" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:singleLine="false" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
app:layout_constraintLeft_toLeftOf="parent" |
||||
app:layout_constraintRight_toLeftOf="parent" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
Loading…
Reference in new issue