چند وقت پیش با دوستان در مورد این صحبت میکردیم که چطور میشه قابلیت Text Selectionیی شبیه تلگرام پیادهسازی کرد. پیچیدگی مسئله توی اینه که اگر توی CustomView متنی draw بشه، دیگه از قابلیت TextSelection خود اندروید نمیشه استفاده کرد و دولوپر باید راهی پیدا کنه.
چون بنظرم چالش جالبی میومد، آخر هفته چند ساعتی برای پیدا کردن راه حلش تحقیق کردم و این مقاله در واقع گزارشی از اون فعالیت چند ساعته برای پیدا کردن حل چالش و جواب سه سوال زیر هست:
۱- چطوری میشه یه متن چند خطی رو Draw کرد؟ (اگر از متد drawText آبجکت Canvas استفاده کرده باشید، حتما میدونید که بدرد اینجور کارها نمیخوره و ساده هست).
۲- وقتی کاربر CustomView رو لمس میکنه، چطوری میشه فهمید نقطهی لمسش روی چه کاراکتریه و از کجا Selection باید شروع بشه؟
۳- چطوری میشه از اولین کاراکتری که کاربر لمس کرده تا جایی که الان انگشتش هست رو هایلایت کرد؟ (مثلا رنگ متنو عوض کرد که کاربر بفهمه چه بخشی از متن رو تا الان انتخاب کرده)
همیشه اولین گام برای حل یه چالش دیدن سورس پروژههای متنباز مشابه با چالش هست. خوشبختانه تلگرام خودش متنبازه و میشه سورس خودشو بررسی کرد. البته چون پروژهاش خیلی بزرگه از طریق گیتهاب توی ریپوی تلگرام دنبال کلمههایی مثل Text Selection، Selection و … گشتم تا سریعتر بتونم اون بخشهای مرتبط به مسئلهی خودمو پیدا کنم. خوشبختانه به نتیجه رسیدم و توی نتایج جستجو کلاس TextSelectier.java رو پیدا کردم.
ولی خب یه مشکلی بود، متاسفانه این کلاس ۲۵۰۰خط کد داشت و بررسی خط به خطش خیلی سخت بود. به همین دلیل بجای اینکه کد رو خط به خط بررسی کنم، فقط مروری کدهارو یه نگاه مینداختم تا ببینم جایی از کدی هست که از متغیرهایی مثل x و y استفاده کنه و کاراکتری چیزی پیدا کنه یا نه؟! (تقریبا مطمئن بودم اگر جایی از x و y استفاده کنه، به مختصات نقطهی لمس کاربر یا همون محل انگشت کاربر روی صفحه ربط داره)
تلاشها نتیجه داد و یه متد به اسم getCharOffsetFromCord توی خط ۱۵۸۲ پیدا کردم. اسمش دقیقا همون بود که میخواستم. البته متوجه نشدم پارامترهای ورودیش دقیقا چی هست ولی این بخش از کدهای متد چشممو گرفت:
حدسم این بود از روی مقدار y تشخیص میده که نقطهی لمس کاربر روی کدوم خط از متن قرار داره. نکتهی مهم این کد اون آبجکت layout هست که بررسی میکنه آیا مقدار y بین مختصات بالا و پایین خط iام قرار میگیره یا نه؟! اگر بگیره یعنی کاربر یه بخشی از اون خط رو لمس کرده.
اولش فکرکردم اون آبجکت layout برای یه کلاسی هست که خود تلگرام توسعه داده ولی بالاتر دیدم تایپ این آبجکت از نوع android.text.StaticLayout هست. اهمیت این کشف در این نکته هست که متوجه شدم خود اندروید یه سری API برای پیادهسازی چالش ما داره و لازم نیست همه کارهارو خودمون بکنیم، در ضمن بجای خوندن کدهای تلگرام میشه از داکیومنتهای خود اندروید استفاده کرد و کار راحتتر میشه.
قبل اینکه ببینیم StaticLayout چیه، بهتره در مورد والدش یعنی android.text.Layout صحبت کنیم. اونجور که من فهمیدم، بیشتر زحمتهارو این کلاس برای نمایش متن توی اندروید میکشه. وظیفهاش مدیریت نمایش متن روی UI هست. یه متد به اسم draw داره که Canvas میگیره و متن رو با خصوصیاتی که براش تعریف شده draw میکنه. این کلاس سه تا فرزند با ویژگیهای متفاوت داره که بر حسب نیاز باید از یکیشون استفاده کرد:
-- کلاس BoringLayout - سادهترین فرزند هست، برای متنهای تک خطی میشه استفاده کرد که هیچ کاراکتری خاصی نداشته باشن.
-- کلاس StaticLayout - باهاش میشه متنهای چند خطی رو draw کرد. امکانات بیشتری نسبت به BoringLayout داره.
-- کلاس DynamicLayout - همهی قابلیتهای StaticLayout رو داره، در ضمن اگر متن تغییر کنه، خودشو آپدیت میکنه.
اگر بخوام یه مثال واقعی از نحوهی استفادهی دو تا کلاس اول بزنم، میتونم به کلاس ReactTextShadowNode در فریمورک ReactNative اشاره کنم. وقتی توی گوگل دنبال اطلاعات بیشتر بودم بهش رسیدم. برای اینکه این کلاس بتونه به بهترین پرفورمنس ممکن متن رو Draw کنه، اول مقدار دو متغیر زیر رو محاسبه میکنه و بعد با سه تا شرط انتخاب میکنه که متن رو با BoringLayout نشون بده یا از StaticLayout استفاده کنه.
متد isBoring چک میکنه آیا این متن رو میشه از طریق BoringLayout کشید یا نه، متد getDesiredWidth هم محاسبه میکنه که عرض صفحه اگر چقدر باشه میشه متن هر پاراگراف رو توی یه خط نشون داد.
کلاس ReactTextShadowNode شرطهاشو به شکل زیر نوشته (البته سادهترش کردم که راحتتر قابل فهم باشه):
مثلا شرط دوم رو اگر چک کنید اینجوریه که اگر نتیجه isBoring نال نبود (متن قابلیت نمایش توی BoringLayout رو داره) و عرض صفحهای که میخوایم متن رو توش نشون بدیم از مقدار desiredWidth بیشتر بود (یعنی متن رو میشه توی یه خط در صفحه نشون داد)، برای کشیدن متن از BoringLayout استفاده کرده.
راستی توی سیستمعاملهای قدیمیتر از M برای ساخت یه Instance از StaticLayout باید از Constructor کلاس و در سیستمعاملهای جدیدتر از Builderش استفاده کرد. بازم توی کد کلاس ReactNative میشه مثال واقعیشو دید:
برای ساخت یه Instance از کلاس BoringLayoutهم باید از متد استیتک Make استفاده کرد. مثل زیر:
برای اینکه دقیقتر بدونید هر پارامتر برای چیه، بهتره داکیومنت اندروید رو نگاه کنید، البته این مقاله توی مدیوم هم در رابطه با StaticLayout خیلی خوبه. تقریبا همه نکات مهم برای draw کردن متن با StaticLayout رو میگه.
تا اینجا پاسخ سوال اول مشخص شد. پس با استفاده از StaticLayout میشه متنهای چند خطی رو توی یه CustomView کشید. در ادامه باید تشخیص بدیم که اگر کاربر CustomView رو لمس میکنه، نقطهی لمس شده روی کدوم کاراکتر هست.
برای اینکه تشخیص بدیم کاربر کدوم کلمه رو لمس کرده به سه چیز نیاز داریم.
۱- پیدا کردن مختصات X و Y نقطهی لمس کاربر
۲- پیدا کردن خطی که توسط کاربر لمس شده
۳- پیدا کردن مکان کاراکتر لمس شده در خط پیدا شده در مرحله دوم
مختصات نقطهی لمس کاربر رو صفحه گوشی رو میشه با override کردن onTouch پیدا کرد. یه کد ساده در حد زیر هم برای کار ما کافی هست.
وقتی کاربر انگشتش رو روی صفحه میذاره، مختصات زیر انگشتش به عنوان نقطهی شروع Selection متن در دو متغیر startX و startY نگهداری میشه. بعد همزمان با حرکت دست کاربر روی صفحه دو متغییر currentX و currentY مقداردهی میشن و همیشه نقطهی پایان Selection رو توی خودشون نگه میدارن.
ولی برای پیدا کردن اینکه چه خطی رو لمس کرده، باید یکم بیشتر تلاش کرد. با الگو گرفتم از کد تلگرام، متد زیر رو درست کردم.
دونه دونه چک میکنه ببینه y نقطهی لمس شده توسط کاربر توی محدودهی کدوم خط قرار میگیره و Index اون خط رو return میکنه.
حالا در آخرین گام برای اینکه تشخیص دادن اینکه کدوم کاراکتر در خط لمس شده میشه از متد getOffsetForHorizontal استفاده کرد. کارش اینه میگه توی خط iام و مختصات x، کدوم کاراکتر قرار داره و Index کاراکتر توی رشته رو به ما میده.
با ترکیب استفاده از دو متد getTouchedLine و getTouchedChar و دو متغیر startX و startY میشه مکان کاراکتری که کاربر برای شروع Selection متن لمس کرده رو پیدا کرد. مثل زیر:
نقطهی پایانی Selection با جا به جا کردن انگشت کاربر روی صفحه جا به جا میشه. با همین دو متد بالا و دو متغیر currentX و currentY میشه نقطهی پایانی رو هم پیدا کرد.
برای هایلایت کردن دو روش پیدا کردم. روش اول سادهتر هست و نیاز نیست هیچ محاسبهای براش انجام داد ولی یه ضعفی داره که در ادامه نشونش میدم، روش دوم یه سری محاسبات داره اما خب خیلی شبیهتر به قابلیت TextSelection تلگرام هست. در ادامه هر روش رو توضیح میدم.
روش اول - استفاده از متد getSelectionPath
این روش رو تصادفی وقتی داکیومنتهای مرتبط به کلاس Layout رو میخوندم پیدا کردم. این متد از شما مکان کاراکتر شروع و پایان Selection رو به همراه یه آبجکت از نوع Path میگیره و بعدش اون مسیری که باید برای Selection روی صفحه هایلایت بشه رو توی اون آبجکت Path پر میکنه. من یه همچین متدی برای استفاده ازش نوشتم.
بعدم خیلی راحت میتونید این Path رو بدید به Canvas تا مثل زیر براتون هایلایت رو انجام بده:
نتیجه کار این شکلی میشه.
مزیت این روش اینه شما هیچ محاسبهای انجام نمیدید و خیلی ساده هست اما مشکلی که داره اینه حتی فضاهای خالی رو هم داره هایلایت میکنه و خیلی جالب نیست.
روش دوم - استفاده از روش کاستوم
توی این روش باید بدون از استفاده از متدهای کلاس Layout و براساس مکان اولین کاراکتر لمس شده در خط شروع Selection تا آخرین کاراکتر در خط پایان Selection اینکه از هر خط چقدر باید هایلایت بشه رو محاسبه کنیم.
از حالت سادهاش شروع میکنیم، فرض کنید کاربر از وسط خط ۲ شروع کرده و تا وسط خط ۵ Selection رو ادامه داده. برای خط ۳ و ۴ محاسبهی زیادی نیاز نیست، فقط باید بدونیم کاراکتر شروع و پایان این خطها توی چه مختصاتی هست و روش یه مستطیل draw کنیم. برای اینکه بتونم محدودهی یه خط رو پیدا کنم متد زیر رو درست کردم:
شماره خط رو میگیره و rect درست میکنه که دور خط هست و کل خط رو شامل میشه. با این متد هم روی خط رو هایلایت میکنم:
حالا سراغ حالت بعدی میریم که محاسبات بیشتری نیاز داره، فرض کنید کاربر از اوایل خط ۲ Selection رو شروع کرده و تا اواخر همون خط ادامه داده. توی این حالت باید مختصات کاراکتر شروع Selection و کاراکتر پایان Selection رو در خط رو پیدا کنیم و بعد هایلایت کنیم. با استفاده از متد getPrimaryHorizontal میشه مختصات یه کاراکتر رو پیدا کرد، Index کاراکتر در رشته رو میگیره و مختصات رو میده.
از getPrimaryHorizontal استفاده کردم و متد زیر رو برای پیدا کردن مختصات شروع و پایان هایلایت نوشتم.
برای نقطهی شروع فقط از متد getPrimaryHorizontal استفاده کردم ولی برای نقطه پایان چک کردم اگر مکان کاراکتر از آخر خط بیشتر بود (افتاده بوده توی خط بعدی)، بجای استفاده از getPrimaryHorizontal همون مختصات آخر خط رو که از getRectText گرفتن به عنوان پایان گذاشتم. در آخر هم از draw استفاده کردم.
از همین متد میشه در حالتی که کاربر چند خط رو میخواد هایلایت کنه برای هایلایت کردن اولین خط و آخرین خط استفاده کرد (برای خطهای وسط قبلا drawFullSelection رو توضیح دادم). فقط برای خط شروع ورودیهای متد میشه مکان کاراکتر شروع لمس و مکان کاراکتر پایان خط (توی خط شروع Selection باید تا آخرین کاراکتر خط هایلایت بشه) و توی خط پایان ورودیهای متد میشه کاراکتر اول خط تا مکان آخرین کاراکتری که کاربر لمس کرده (توی خط پایان Selection باید تا اول خط تا زیر کاراکتری که کاربر انگشتش روش هست هایلایت بشه).
کد کل متد هایلایت کردن اینجوری میشه:
نتیجه نهایی هم اینجوری میشه:
توی این حالت نتیجه خیلی شبیهتر به تلگرام میشه و یجورایی چالش حل میشه. هووراااا D:
نکتهی مهم: در نظر داشته باشید که موقع نوشتن این مقاله نکات پرفورمنسی برام مهم نبوده و هدفم توضیح دادن متدها و نحوهی استفادهشون در حل جالش بوده. به عنوان نمونه توی متد onDraw نباید آبجکتی رو new بکنید ولی برای اینکه کد تمیزتر باشه اینکارو کردم تا درکش راحتتر باشه. در ضمن توی این مقاله حالتهای خاص هم بررسی نشده، مثلا اینکه متن RTL باشه یا کاربر بخواد از پایین Selection رو شروع کنه و به سمت بالا دستشو ببره، اگر بخواید واقعا همچین قابلیتی رو توی پروژه اصلیتون پیاده کنید، حتما باید حواستون به اینجور چیزها باشه. این مقاله فقط شروعی برای پیادهسازی هست.