Flutter | ดีไซน์ กับอนิเมชั่น Digital Clock กันเพียวๆ ไม่ง้อ 3rd party package

รอบนี้กลับมาดีไซน์กันอีกรอบใน Flutter และเนื่องจากเราจะสร้างเองหมดเลย ดังนั้นเตรียมใจว่าเราจะหนีเลขไม่พ้นอย่างแน่นอน หลักๆ มี 2 เรื่อง เมทริกซ์ กับ ตรีโกณ ลองเดากันก่อนได้ว่าส่วนไหนใช้อะไรบ้าง

เราจะมาทำนาฬิกา(Clock) ที่ให้อารมณ์กึ่งๆระหว่าง Analog(mechanic) กับ Digital กัน ตัวเลขที่โชว์ จะเป็น 7-Segment Display ที่เราสร้างขึ้นเอง เวลามีการอัพเดท เราจะทำอนิเมชั่น Flip หรือพลิกนั่นเอง ซึ่งจะให้อารมณ์ 2.5D แล้วเนื่องจาก Flutter ไม่ใช่ 3D engine เราก็ต้องมีการพลิกแพลงกันหน่อย

ด้านบนขวา จะเป็นสวิทช์(Switch) ใช้เลือกระหว่าง Light กับ Dark Mode

ด้านล่างจะเป็นพื้นหลัง(Background) รันคลื่น เคลื่อนตัวไปเรื่อยๆ 3 อันซ้อนกัน

มาเริ่มกัน

สร้างโปรเจค Flutter หลังจากนั้นเตรียมสร้างไฟล์ดังนี้

เราจะมี widget 3 อัน

  1. Switch — อันนี้ เราจะใช้ของ Material design ที่ Flutter มีให้อยู่แล้ว
  2. Clock — เราจะสร้างจากการสร้าง dash ก่อน เอามารวมกัน 7 อันได้ digit แล้วเอา digit มาใช้แสดงนาฬิกาอีกที
  3. Wave — เราจะมาวาดคลื่นกันใช้ CustomPainter

เนื่องจากแอปเรามีการใช้อนิเมชั่น ดังนั้นเราจำเป็นต้องมี ValueNotifier เพื่อส่งสัญญาณให้อนิเมชั่น นั้นๆ รับทราบได้

ภาพรวมคือ หลังเราสร้าง 3 ส่วนเสร็จ เราจะเอามา Stack / ซ้อนกัน แล้วเนื่องจากไม่อยากใช้ AppBar ก็จะแนะนำให้ครอบ body ใน Scaffold ด้วย SafeArea

การเรียงลำดับ Stack ตามนี้จะเป็นการเรียง Switch, Clock, Wave จากด้านหน้าสุดไปหลังสุด

⚠️ หมายเหตุ ในบทความนี้ เราจะเน้นไปส่วนการสร้าง Graphics มากกว่า Logic ดังนั้นจะเน้นคำอธิบายเฉพาะส่วน Design and Animation

ℹ️ แนะนำให้ clone source code มาไถตามไปด้วย ที่ด้านล่างสุด

เริ่มที่ Dash กันก่อน

เปิดไฟล์ widgets/dash.dart

มันจะมีทั้งเส้นแนวตั้ง DashVertical และแนวนอน DashHorizontal เรามาอธิบายที่ DashVertical กัน

CustomPainter เป็นกระดานไว้ให้เราวาดรูป เมื่อเราต้องการวาดเราจะทำใน paint()

ใน paint เราจะมี canvas เราสามารถใชตัวนี้วาดรูปพื้นฐานได้เลย เช่นสี่เหลี่ยม วงกลม แต่ถ้าเราอยากวาดของเราเอง เราก็จะใช้ canvas.drawPath ในนี้ เราจะส่ง Path() กับ Paint()

Path จะก็จะสั่งให้วาดรูปตามที่เราคำนวนไว้แล้ว

Paint จะเป็นข้อมูลการลงสี เราจะวาดแค่เส้นรอบรูป หรือลงสีทั้งรูปก็ได้ สามารถ blend colorFilter … ได้ แนะนำให้ลองไปแก้เพิ่มดูได้ แต่สำหรับครั้งนี้เราเอาแค่นี้ก่อน

ตอนเรียกใช้ก็ให้เรียก CustomPaint แล้วใส่ขนาด กับ DashVerticalPainter ไป

ต่อมา

เราจะเอา CustomPaint อันนี้ไปทำอนิเมชั่นต่อใน DashVertical Widget

ในส่วน constructor เราก็จะรับตัว ขนาด สี และตัวบอกสถานะ ซึ่งมี 2 ตัว status กับ isDark

ในส่วน state เราก็จะรับตัวแปรจาก constructor มา มีการกำหนดค่า Default ให้ถ้าไม่ได้ส่งค่ามา เมื่อรัน initColorValue() เท่ากับว่าเรามีค่าให้ตัวแปรเกือบหมดแล้ว ขาดแค่ animationController กับ animationValue

เราจะเอาฟังก์ชั่นเมื่อกี้มารันใน initState นอกจากนี้ก็จะมากำหนดค่า animationController ด้วย ให้เวลา 800 ms เวลานี้เป็นเวลาที่ dash จะใช้พลิก (ไม่ควรเกิน 1000 ms ไม่งั้นจะเสร็จไม่ทันก่อนการอัพเดทครั้งถัดไป)

ต่อมาเราก็จะนำมาใช้กำหนด animationValue ต่อ ใส่ curve ให้ได้ความรู้สึกของการพลิกแบบมีฟิสิกส์

สุดท้ายเราก็จะฟังว่า ถ้าตัวเลขเปลี่ยน status หรือ โหมดสีเปลียน isDark เราก็จะรันอนิเมชั่น ด้วยฟังก์ชั่น updateDash

ใน updateDash() เราก็จะเช็คว่า สีต้นทาง ปลายทางควรเป็นสีไหน เมื่อเช็คเสร็จก็สั่งรันอนิเมชั่น

ท้ายสุดอย่าลืม dispose animationController หลังใช้งานเสร็จ

มาดูในส่วนสร้างอนิเมชั่นต่อ การพลิก เราจะเอา Transform เข้ามาช่วย แล้วด้วยความ Custom ของเรา เราจึงต้องสร้าง Matrix transform ของเราเอง

ดังนั้นที่ transform เราจะเอา Matrix identity เป็นตัวตั้งต้น แล้วเราหมุนรอบแกน Y จึงมี rotateY

ส่วน setEntry ที่ row 3 col 2 เป็นการเพิ่มความลึกตื้น ขึ้นกับแกนตำแหน่ง Y ที่จุดนั้นๆ ให้ความเหมือนจริงมากขึ้น

origin เราจะตั้งไว้ตรงกลาง หาจากความกว้าง และความยาวมาหาร 2 (หรือจะใช้ alignment: Alignment.center แทน origin ก็ได้)

child เราจะสลับสีเมื่อค่า animationValue.value > 0.5 หรือว่าเป็นจุดที่กำลังตั้งฉากกับสายตาเรา ทำให้เหมือนว่า Dash เรามีด้านหน้า ด้านหลัง

ตอนนี้ส่วนอนิเมชั่นก็หมดแล้ว เหลือให้เราเอามาประกอบกันให้เป็น 7-segment

ขอข้ามส่วน DashHorizontal เพราะมีความคล้ายกัน ต่างกันที่ขณะวาดเราจะคำนวนสำหรับแนวนอน และเวลา transform จะ rotateX แทน และ setEntry ที่ row 3 col 1

Digit

เราจะสร้าง class DigitDash

ไว้ใช้สำหรับความเป็นระเบียบใน class Digit เวลา init ค่า ValueNotifier ของแต่ละ Dash

ใน state เราก็จะ init และให้ฟังถ้าตัวเลขต้องอัพเดท เราจะเอาตัวเลขมาแปลงเป็น 7-Segment ด้วยมือเรา

เราจะใช้ตารางเปรียบเทียบ แล้วให้ loop ไล่เช็คและเปลี่ยนให้เรา

หมายเหตุ ในตารางเปรียบเทียบ false หมายถึงตอนโชว์ฝั่ง on ส่วน true หมายถึงตอนโชว์ฝั่ง off

สุดท้าย เวลา build เราก็เอา Vertical กับ Horizontal Dash มาเรียงใน Row กับ Column

แล้วเราก็เสร็จ Digit แล้ว

มารวม Digit ให้กลายเป็น Clock

กลับมาที่ main.dart

เราก็จะนำ Digit มาใส่ใน _buildClock()

จากจุดนี้ น่าจะเริ่มสงสัยว่าตัวแปร dashHeight minute0 isDark … มาจากไหน

เราจะประกาศตัวแปรไว้ด้านบน จะเห็นได้ว่าเราจะสร้าง ValueNotifier ของแต่ละ Digit ไว้ที่นี่ แล้วส่งตัวแปรต่ออีกที

isDark ก็จะประกาศค่าตั้งต้นเป็น false ไว้ก่อน แล้วส่งต่ออีกทีเช่นกัน

ตรงนี้จะเป็นตำแหน่งที่เราสร้างตัวเรียกอัพเดทเวลา ต้องขอบคุณที่ Dart รองรับ Stream ในตัว เราเลยใช้มันสร้างข้อมูลเป็นช่วงๆได้ เราเก็บไว้ที่ตัวแปร timer

ต่อมาเราก็ฟังค่าของมัน ด้วย timer.listen() แล้วอัพเดทตัวเลขแต่ละหลักของนาฬิกา

ส่วนนี้จะเป็นการคำนวนความกว้างยาวของ Dash เพราะต้องการให้มัน Responsive

ค่าตรงนี้มาจากการวัดอัตราส่วนของ Dash โดยในครั้งนี้ กำหนดให้ ความกว้าง:ความยาว เท่ากับ 5:1 ที่เหลือก็ต้องลองนับดูแล้วว่ามี Dash ในแนวตั้งกี่อัน แนวนอนกี่อัน แล้วบวกเลขดู จะได้ที่มาของ 143/2 😀

ส่วนยากสุดเสร็จแล้ว กระโดดมาทำอันง่ายสุดบ้าง

Switch

ในไฟล์เดิม main.dart

เราสามารถเรียก Switch() จาก material ที่ Flutter มีให้ได้เลย

ค่าของมันจะฟังจาก isDark และเวลากด เราก็จะอัพเดทค่าตัวนี้ นอกจากนี้เพื่อให้มีการอัพเดท UI ที่ไม่ได้มี Listener เช่น background color เราเลยเรียก setState()

ตำแหน่งของ Switch เราก็วางไว้บนขวา ห่างมา 16 px ซึ่งจุดนี้ก็ไม่ต้องกลัวว่าจะโดน Notification Bar ทับ เพราะเราได้หุ้มด้วย SafeArea() ก่อนหน้าแล้ว

เสร็จอีก 1 ไปอันสุดท้าย

Wave

มาดูที่ไฟล์ widgets/wave.dart กัน

เริ่มที่ CustomPainter

คลื่นสร้างขึ้นมายังไง จุดเริ่มต้นก็มาจากตรีโกณ แบบพื้นฐานสุดก็ Y = sin(X)

แต่ในเมื่อเราอยากได้คลื่นที่พลิ้วไหวและมีความแปรปวน แสดงว่าเราก็ต้องเอาคลื่น หลายๆอันมายำรวมกัน

สำหรับที่ใช้ในนี้ เราจะมีคลื่น 2 อัน

  • อันแรก(สีเขียว) จะเป็นคลื่นที่นิ่งๆ ไม่ได้มี แอมพลิจูด(ความสูงคลื่น)มาก เคลื่อนตัวช้า
  • ส่วนอีกอัน(สีแดง) ตัวนี้จะมี แอมพลิจูดที่กว้างกว่า และความยาวคลื่น(ความกว้างคลื่น)ที่สั้นกว่า มีความแปรปวนสูง ถ้าสังเกตในโค๊ด เราจะใส่เวลาเข้ามาเป็นปัจจัยส่วนนี้ด้วย เพื่อให้เฟสคลื่นไม่ตรงกัน

รวมกันได้(สีฟ้า) คลื่นที่ค่อยๆเคลื่อนตัว มีความแปรปวนกลางๆ ดูเป็นธรรมชาติ(มั้ง😅)

คลื่นจะเคลื่อนตัว หรือ Offset ตามค่า waveOffset ที่จะรับมาจาก AnimationController อีกที

เพื่อความ Random เวลาเรียก Wave Widget แต่ละครั้ง เราจะมี RandomOffset รับมาด้วย

ทั้งคู่เราจะมาอธิบายต่อใน Wave Widget

width กับ height เราก็จะรับมาจาก parent widget อีกที ดังนั้นเพื่อไม่ให้คลื่นล้นออกมาจากความสูงที่กำหนด เราเลยมี highestWaveHeight ที่คำนวนจากกรณีของความสูงที่สูงสุดของคลื่นเรา (สังเกตว่าเราเอาสมการมาแก้ค่าใน sin() ให้เป็น pi/2 ให้หมด) แล้วเอาค่านี้ไป offset ในแกน y

จบ CustomPainter

ต่อ WaveState

ที่ initState() เรามีการประกาศค่าตั้งต้นดังนี้

randomOffset เราใช้ฟังชั่น Random() ให้สุ่มค่าที่อยู่ช่วงคลื่น คือ 0–2pi หรือ 0–6.28 เราเลยสุ่มเลข 0–63 แล้วหาร 10 ค่าตรงนี้ก็ประมาณๆเอา ใครอยากให้สุ่มกว้างกว่านี้ก็ 0–628 หาร 100 ก็ได้ หรือสุ่มมั่วเลยก็ได้ ตามความชอบ

time เก็บค่า AnimationController ตรงนี้เราจะกำหนดให้คลื่นเคลื่อนตัวช้าเร็ว แก้ Duration ตรงนี้ก็ได้ แค่ลองกลับไปดูฟังก์ชั่นที่ใช้สร้างคลื่นแล้วคำนวนดูให้ดี เพราะมีตัวแปรอื่นที่ส่งผลด้วย

waveOffset นำค่าจาก time มา Tween ให้เป็นช่วง 0 ถึง 2pi แล้วดึงค่ามาเลย

แล้วเนื่องจากเรารันคลื่นเรื่อยๆใน background เลยสั่ง time.repeat()

ส่วน build เราก็สร้าง CustomPaint แล้วรับ WavePainter หรือ CustomPainter ที่เราพึ่งสร้างมาได้เลย

นำมาใส่ใน main.dart กัน

ใกล้จะเสร็จแล้ว

ที่ _buildBackgroundWave()

เรานำ Wave Widget มาหุ้มด้วย SizedBox เพื่อให้ CustomPainter รู้ขนาดได้ แล้วให้อยู่ด้านล่างเลยใช้ Align

ทำแบบนี้ 3 ครั้ง ซ้อนกันโดยใช้ Stack ก็เสร็จเรียบร้อยแล้ว

ครบแล้ว 3 ส่วน หน้าตาสุดท้ายก็ควรจะออกมาได้เท่ๆตามนี้

จบอีกหนึ่งบทความ เป้าหมายหลักๆครั้งนี้ก็เพื่อเพิ่มความคุ้นเคยกับเครื่องมือที่ใช้วาดรูปแบบ Custom และการทำอนิเมชั่นให้พิเศษขึ้น เพราะโดยทั่วไป เราก็จะใช้เพียง scale translate rotate แต่เวลาเราอยากสร้าง 2.5D หรือ 3D เราก็ต้องกลับไปพึ่งเลขอีกครั้ง matrix ก็เข้ามาช่วยชีวิตเราตรงนี้ อยากได้อะไรมากกว่านี้ ก็ขึ้นกับจิตนาการจะไปถึงแล้ว หวังว่าทั้งหมดนี้จะเป็นประโยชน์ไม่น้อย

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store